likes
comments
collection
share

用一个登录鉴权来说明 Spring Boot 如何使用 Spring AOP

作者站长头像
站长
· 阅读数 21

面向切面编程

面向切面编程(Aspect Oriented Programming),可以将与业务无关但是被各个业务模块共同调用的逻辑抽取出来,以切面的方式切入到代码中,从而降低系统中代码的耦合度,减少重复的代码。

Spring AOP 是通过预编译方式运行期间动态代理实现程序面向切面编程。

试想我们的项目中有一个接口,它的代码逻辑是这样的:

public R api() {
	查询数据库;
    返回数据;
}

现在我们需要对该接口进行登录验证,只有登录了的用户才能访问该接口,如果用户没有登录,那么返回一个错误结果。此时,最简单的方式就是使用 if-else 进行判断,添加到代码逻辑中。但如果这种接口数量一多,那我们的工作量就势必加大了。

如果后续开发中,我们还需要给接口添加权限验证,只有具有某种权限的用户才能访问接口,那我们又需要添加大量重复代码。

这种应用场景,例如登录校验、权限校验、日志处理等这种多个模块可能会共同调用的代码,我们完全可以使用切面的方式,将逻辑切入到业务模块中。

AOP 的底层实现原理

AOP 底层使用动态代理完成需求,为需要增加增强功能的类生成代理类,有两种生成代理类的方式,对于被代理类(即需要增强的类),如果:

  1. 实现了接口,使用 JDK 动态代理,生成的代理类会使用其接口
  2. 没有实现接口,使用 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 的方法会被增强

切入点表达式可以用上 ||&& 逻辑运算符。

我们会使用到如下几个注解:

注解说明
@Aspect声明某个类是切面,编写通知、切入点
@Before对应前置通知
@AfterReturning对应后置通知
@Around对应环绕通知
@AfterThrowing对应异常通知
@After对应最终通知
@Pointcut声明切入点,标注在一个方法上可以让表达式更简洁

我们编写两个类:

说明
TestController编写一个测试接口,查看效果
TestAOP编写通知代码,即要增强的功能编写切入点,使用切入点表达式

代码如下:


//=================================== 切面代码 ===================================
@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 实现登录鉴权

假设我们有这样一个场景,某个接口需要用户具有管理员权限才能访问,如果没有权限则抛出异常,交给全局统一异常处理。

我们可以使用拦截器完成,也可以使用自定义切面编程实现。

我们需要编写三个类:

说明
@PermissionRole注解,标注在方法上,表明某个接口需要权限控制
PermissionRoleAspect实现登录鉴权的切面代码
TestController编写通知代码,即要增强的功能编写切入点,使用切入点表达式

代码如下:


//=================================== 自定义注解代码 ===================================
@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 声明优先级,值越小优先级越高,越先执行。