AOP设计思想与实用

一、AOP思想

(一)什么是AOP

  百度百科:面向切面编程,通过预编译方式和运行期间动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
  AOP它是一种思想,是对某一类事情的集中处理。换句话说,如果我们在做某件事之前,必须先进行另一件事,而另一件事很多地方都会用到,此时我们就可以把另一件事单独放在一处集中处理。例如访问页面前的登录校验

(二)为什么要使用AOP

用户校验
  往常我们进行登录校验一般的做法是将登录校验的过程封装成一个方法,如果某个路由需要进行登录校验,就进行调用,但是这么做也有其弊端,那就是污染了业务代码,如果有一天我们方法的参数类型或者数量改变,那么就必须对所有调用了这个方法的业务代码进行修改,如果有几千个,那么无疑是一个很大的工程

  AOP的思想正是解决这个问题,往常方法都是被动调用,而 AOP 的做法却是主动出击,如果某个路由需要进行登陆验证,AOP会直接将请求拦截,先进行判断,没问题了之后才可以访问,这么做的好处是一方面不需要具体的业务代码进行手动判断,也就不会对业务代码造成污染;另一方面也就降低了开发和维护的成本

AOP 的用途不止是统一的用户登录验证,还可以实现:

  • 统一日志管理
  • 统一方法执行时间统计
  • 统一的返回格式设置
  • 统一的异常处理
  • 事务的开启和提交等

换句话说,使用 AOP 可以扩充多个对象的某个能力,这也就是为什么说 AOP 是 OOP 的补充和完善

二、Spring AOP

  AOP 和 Spring AOP 的关系就像 IoC 和 DI 的关系,Spring AOP 框架是对 AOP 思想的具体实现
  如果要学习 Spring AOP,那么我们需要先学习 AOP 是如何组成的

(一)AOP 的组成

1. Join Point(连接点)

  Join Point表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问以及异常处理程序块的执行等等,它自身还可以嵌套其它 joint point
  通俗来讲就是各种业务代码中需要借助 AOP 实现的部分

2. Pointcut(切点)

  Pointcut 的作⽤就是提供⼀组规则(使⽤ AspectJ pointcut expression language 来描述)来匹配 Join Point,给满⾜规则的 Join Point 添加 Advice
  通俗来讲 Pointcut 就是 Join Point 的集合,这个集合中的每一个连接点就是具体要解决的某个问题,而存在这个问题的类或方法常常是用通配、正则表达式等方式进行统计

3. Advice(通知)

  Advice 定义了 Pointcut 需要进行的统一操作,它通过 before、after 和 around 来区别是在 Join Point 之前、之后还是替代执行的代码
  也就是执行统一操作的具体代码实现,往往是一个一个方法

Spring 切面类中,可以在方法上使用以下注释,会设置方法为通知方法,在满足(切点)条件之后通知本方法进行调用:

  • 前置通知使用 @Before:通知方法会在目标方法(即要访问的具体业务方法)调用之前执行
  • 后置通知使用 @After:通知方法会在目标方法返回或者抛出异常后调用
  • 返回之后通知使用 @AfterReturning:通知方法会在目标方法返回后调用
  • 抛异常后通知使用 @AfterThrowing:通知方法会在目标方法抛出异常之后调用
  • 环绕通知使用 @Around:通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行相应代码

4. Aspect(切面)

  Aspect由切点(Pointcut)和通知(Advice)组成
  Aspect 就是包含了 Advice、Pointcut 甚至是 Aspect的类,相当于 AOP 实现的某个功能的集合

(二)AOP实现

  接下来我们使用 Spring AOP 来实现以下 AOP 的功能,完成的目标是拦截所有 UserController 中的方法,并执行相应的通知事件
实现步骤如下:

  1. 添加 Spring AOP 框架的支持
  2. 定义切面和切点
  3. 定义通知

1. 添加 Spring AOP 框架的支持

  我们仍然是创建 Spring Boot 项目,添加的依赖仍然是Spring Boot DevToolsLombokSpring Web这三个,有同学好奇,为什么不添加 Spring AOP 的依赖,原因是我们在创建项目时顺便可以添加的依赖被称作起步依赖,是有限的,不包含所有依赖,Edit里也没有。因此需要我们去 Maven 仓库复制之后手动添加
  由于我们创建的是 Spring Boot项目,因此我们需要添加的是 Spring Boot Starter AOP依赖,又由于作者创建的 Spring Boot 项目版本是 2.7.11,因此也需要选择 2.7.11 版本的 Spring AOP依赖,最后复制并粘贴到 pom.xml 即可
Spring Boot AOP
引入依赖

2. 定义切面和切点

对切面和切点的示例代码如下:

@Aspect // 告诉框架我是一个切面类
@Component // 保证切面类随着项目的启动而启动
public class UserAspect {
    /**
     * 定义切点,配置拦截规则
     * execution 固定写法,表示拦截
     * * 表示拦截所有类型
     * com...UserController路径表示拦截的类
     * .*表示拦截该类的满足后面括号里的条件的所有方法
     * (..) 表示不管参数个数都进行拦截
     */
    @Pointcut("execution(* com.example.demo.controller.UserController.*(..))")
    public void pointcut() {}
}

UserController中的方法:

@RequestMapping("/user")
@RestController
public class UserController {

    @RequestMapping("/hello")
    public void hello(String name) {
        System.out.println("hello " + name);
    }

    @RequestMapping("/hi")
    public void hi() {
        System.out.println("hi world");;
    }
}

  拦截规则限定了哪些类的哪些方法需要进入该切点,pointcut 方法为空方法,它不需要有方法体,此方法起到“标识的作用”,标识下面 Advice 方法具体指的是哪一个切点(因为切点可能有很多个)


切点表达式说明:
AspectJ 支持三种通配符

  • *:匹配任意字符,只匹配一个元素(包、类、方法、方法参数)
  • ..:匹配任意字符,可以匹配多个元素,在表示类时,必须和 *联合使⽤
  • +:表示按照类型匹配指定类的所有类,必须跟在类名后⾯,如 com.cad.Car+,表示继承该类的所有⼦类包括本身

切点表达式由切点函数组成,其中 execution() 是最常用的切点函数,用来匹配方法,语法为:

execution(<修饰符><返回类型><包.类.方法(参数)><异常>)

其中修饰符和异常可以省略,具体值和表达的含义如下:
1. 修饰符,一般省略

public 公共方法
*任意

2. 返回值,不能省略

void 没有返回值
String 返回值为字符串类型
等等各种类型的返回
*返回任意类型

3. 包

com.example.demo 固定包
com.example.demo.*.service demo 包下面,最后包名为service的包
com.exampli.demo… demo包下的所有子包
com.exampli.demo.*.service… demo包下,任意service包名下的所有子包

4. 类

UserController 指定类
Controller 以 Controller 名字结尾的类
User
以 User 开头的类
*任意类

5. 方法名,不能省略

hello 固定方法
hello* 以 hello 开头的方法
*hello 以 hello 结尾的方法
*任意方法

6. 参数

() 无参
(Integer) 一个整型
(Integer, Integer) 两个整型
其他类型与上述类似
(…) 参数任意

7. throws,可省略,一般不写

3. 定义 Advice

  Advice 定义的是被拦截的方法具体要执行的业务,比如用户登录权限验证方法就是具体要执行的业务,接下来我们来演示一下上述提到的前置、后置以及环绕通知注解的使用,具体实现如下:

@Aspect
@Component
public class UserAspect {
    @Pointcut("execution(* com.example.demo.controller.UserController.*(..))")
    public void pointcut() {}

    // 前置通知
    @Before("pointcut()")
    public void beforeAdvice() {
        System.out.println("执行 before 通知");
    }

    // 后置通知
    @After("pointcut()")
    public void afterAdvice() {
        System.out.println("执行 after 通知");
    }

    // 环绕通知
    @Around("pointcut()")
    // ProceedingJoinPoint 就是执行连接点对象,即我们需要知道我们拦截的具体是哪个方法
    public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        Object obj = null;
        System.out.println("Around 方法开始执行");
        // 执行被拦截的业务代码
        // 相当于我们执行了一半的环绕通知方法,调用执行业务代码
        // 等业务代码执行完毕,继续执行后面的环绕通知方法
        obj = joinPoint.proceed();
        System.out.println("Around 方法结束执行");
        return obj;
    }
}

测试结果:
通知实现

(三)Spring AOP 实现原理

  Spring AOP 是构建在动态代理基础上,因此 Spring 对 AOP 的⽀持局限于⽅法级别的拦截
我们需要明确两个概念:

  • 静态代理:目标类和代理类实现了相同的接口,在代理类中依赖了目标类,代理类的方法中调用了目标类的方法,并做了一些增强性的工作
  • 动态代理:在程序的执行过程中,使用jdk的反射机制,创建代理对象,并动态的指定代理的目标类

  静态代理和动态代理最大的区别就是动态代理可以在不改变原来目标方法功能的前提下,在代理中对目标方法功能进行增强
  Spring AOP 支持 JDK Proxy 和 CGLIB 方式实现动态代理。Spring 默认情况下,实现了接口的类,使用 AOP 会基于 JDK 生成代理类,没有实现接口的类,会基于 CGLIB 生成代理类。Spring Boot默认情况下使用 CGLIB
JDK 和 CGLIB 实现的区别

  1. JDK 实现:要求被代理类必须实现接⼝,之后是通过 InvocationHandlerProxy,在运⾏时动态的在内存中⽣成了代理类对象,该代理对象是通过实现同样的接⼝实现(类似静态代理接⼝实现的⽅式),只是该代理类是在运⾏时期,动态的织⼊统⼀的业务逻辑字节码来完成,节省了我们开发和维护的成本
  2. CGLIB 实现:被代理类可以不实现接⼝,是通过继承被代理类,在运⾏时动态的⽣成代理类对象。但是也导致一个问题,它无法代理被 final 修饰的类,此时只能使用 JDK 实现

织入(Weaving):代理的生成时机
  织入是把切面应用到目标对象并创建新的代理对象的过程,切⾯在指定的连接点被织⼊到⽬标对象中
  在目标对象的声明周期里有多个点可以进行织入,包括编译器,类加载期,运行期。Spring AOP 就是在运行期,也就是在应用运行的某一时刻将切面织入

最后,Spring AOP做的业务方向更偏向于事务、日志、分布式锁等,统一用户登录验证,统一异常处理等业务操作实现 Spring 提供了更好的方式

三、基于 AOP 思想实现 Spring Boot 统一功能处理

  接下来我们介绍 Spring Boot 统⼀功能处理模块

  1. 统一用户登录身份校验
  2. 统一数据格式返回
  3. 统一异常处理

(一)统一用户登录身份校验

1. Spring AOP 用户统一登录验证的问题

  为什么不能在 Spring AOP 切面中实现用户登录信息统一校验,主要是存在两个问题

  1. 很难获取到 HttpServletRequest 对象,也就很难获取到 HttpSession 对象
  2. 我们要对一部分方法进行拦截,而另一部分方法不拦截,比如注册方法和登录方法,这样的话排除方法的规则很难定义,甚至无法定义

那么我们该如何解决呢?

2. Spring 拦截器

  Spring 拦截器和 Spring AOP都是对 AOP 思想的具体实现,但是它是直接内置到 Spring 框架中的,不需要像 Spring AOP 一样引入依赖。对于上述问题 Spring 提供了具体的实现拦截器:HandlerInterceptor,拦截器的实现分为以下两个步骤:

1. 创建自定义拦截器,实现 HandlerInterceptor 接口的 preHandle (执行具体方法之前的预处理)方法
代码实现:

public class LoginInterceptor implements HandlerInterceptor {
    /**
     * 此方法返回 boolean 类型,如果为 true,表示验证通过,进入业务代码
     * 如果为 false,表示验证未通过,进行处理
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 仍然是通过 request 获取到 HttpSession 对象
        HttpSession session = request.getSession(false);
        // 假设 session 中存储了 userInfo 的数据,对 session 进行校验
        if(session != null && session.getAttribute("userInfo") != null) {
            // 用户登录信息校验通过
            return true;
        }
        // 如果用户信息校验未通过,跳转到登录界面 login.html
        // 或返回 403 / 401 没有权限码
        response.sendRedirect("/login.html");
        // response.setStatus(403);
        return false;
    }
}

2. 将自定义拦截器加入 WebMvcConfigurer 的 addInterceptors 方法中
代码示例:

/**
 * Created with IntelliJ IDEA.
 * Description: 对当前系统的配置文件操作的类
 */
@Configuration
public class AppConfig implements WebMvcConfigurer {
    // 添加一个拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor2())
                .addPathPatterns("/**") // 拦截所有请求
                .excludePathPatterns("/user/login")
                .excludePathPatterns("/user/reg")
                .excludePathPatterns("/login.html");
    }
}

3. 拦截器实现原理

请求执行过程
  Spring 底层的逻辑是:在执行 Controller 之前,会先调用预处理方法 applyPreHandler,而 applyPreHandler 底层源码会先获取到所有的拦截器 HandlerInterceptor,并for循环执行所以拦截器中的 preHandle 方法,这就是拦截器的实现原理
  Spring 中的拦截器也是通过动态代理和环绕通知的思想实现的

4. 扩展:添加统一访问前缀

  大多时候,我们在一台服务器上不会只跑一个项目,但是我们在给一台服务器配域名的时候只能配一个ip的一个端口,因此我们为了确认用户访问的是一个项目,就要为一个项目的所有请求地址添加统一 api 前缀

我们有两种实现方式:

  1. 在系统的配置文件中配置

代码示例:

// 和 添加拦截器位置一样,也是在 WebMvcConfigurer 类里设置
// 为所有的接口添加统一前缀
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
    // 第一个参数表示统一前缀名
    // 第二个参数是 lambda 表达式,表示所有的 Controller 都要添加这个前缀
    configurer.addPathPrefix("/zhangsan", c -> true);
}
  1. 直接在application.properties或者application.yml中设置
server.servlet.context-path=/lisi

(二)统一异常处理

  统一异常处理使用的是 @ControllerAdvice + @ExceptionHandler 来实现的,@ControllerAdvice 表示控制器通知类,是 AOP 思想的实现,@ExceptionHandler 是异常处理器,两个结合表示当出现异常的时候执行某个通知,也就是执⾏某个方法事件,具体代码如下:

@ControllerAdvice
@ResponseBody
public class ExHandler {
    // 接收所有异常并规定返回格式
    // Exception.class 表示截取的是所有异常类型
    @ExceptionHandler(Exception.class)
    public HashMap<String, Object> exception(Exception e) {
        // 自定义异常返回数据格式
        HashMap<String, Object> map = new HashMap<>();
        map.put("code", "-1"); // 状态码
        map.put("msg", e.getMessage()); // 状态码的描述信息
        map.put("data", "null");
        return map;
    }

    // 接收空指针异常并返回规定格式
    @ExceptionHandler(NullPointerException.class)
    public HashMap<String, Object> nullException(NullPointerException e) {
        HashMap<String, Object> map = new HashMap<>();
        map.put("code", "-1"); // 状态码
        map.put("msg", "空指针异常:" + e.getMessage()); // 状态码的描述信息
        map.put("data", "null");
        return map;
    }
}

异常代码:

@RequestMapping("/hello")
public String hello() {
    Integer test = null;
    test.toString();
    System.out.println("hello world!");
    return "hello world";
}

@RequestMapping("/login")
public String login() {
    int result = 10 / 0;
    System.out.println("执行了 login");
    return "login";
}

测试结果:
空指针异常处理
算术异常处理

(三)统一数据返回格式

1. 为什么要统一数据返回格式

  在公司开发项目中,因此前后端开发的人员大概率不止一个,不同的人返回数据格式是不同的,而如果不进行统一,前端人员接收响应进行处理时将会很痛苦,因此统一数据返回格式是十分重要的,它有以下好处:

  1. 方便前端程序猿更好的接收和解析后端数据接⼝返回的数据
  2. 因为按照统一格式返回,降低了前端程序员和后端程序员的沟通成本
  3. 有利于项⽬统⼀数据的维护和修改
  4. 有利于后端技术部⻔的统⼀规范的标准制定

2. 统一数据返回格式的实现

  统一的数据返回格式可以使用 @ControllerAdvice + ResponseBodyAdvice 的方式实现,具体实现代码如下:

/**
 * 自定义统一处理返回数据格式
 * @param body 原始的数据返回结果
 * @param returnType
 * @param selectedContentType
 * @param selectedConverterType
 * @param request
 * @param response
 * @return 返回 新的 body
 */
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
    // 这里选择返回 json 的数据格式
    HashMap<String, Object> result = new HashMap<>();
    result.put("code", "200");
    result.put("msg", "");
    result.put("body", body);
    return result;
}

  上述写法有些问题,当原 body 是字符串类型时,都 put 到 map 中之后直接返回就会报出java.util.HashMap cannot be cast to java.lang.String异常,具体原因可以参考下面这篇文章(https://blog.csdn.net/qq_37170583/article/details/107470337 ),修正的写法作者在这里推荐一种:

// 操作 jackson 的工具
// Spring Boot内置了 Jackson
@Autowired
private ObjectMapper objectMapper;

/**
 * 自定义统一处理返回数据格式
 * @param body 原始的数据返回结果
 * @param returnType
 * @param selectedContentType
 * @param selectedConverterType
 * @param request
 * @param response
 * @return 返回 新的 body
 */
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
    // 这里选择返回 json 的数据格式
    HashMap<String, Object> result = new HashMap<>();
    result.put("code", "200");
    result.put("msg", "");
    result.put("body", body);
    if(body instanceof String) {
        try {
            return objectMapper.writeValueAsString(result);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }
    return result;
}

  说明:这个返回结果设置还是比较死板的,我们往往会在它之前设置一个工具类来设置统一对象,只有工具类不行了,才会用到它

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

求索1024

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值