目录
3.1 @ControllerAdvice + @ResponseBody / @RestControllerAdvice
1. 拦截器
1.1 什么是拦截器
拦截器是 Spring 框架提供的核心功能之一, 主要用来拦截用户的请求, 在指定方法前后, 根据业务需要执行预先设定的代码.
举个例子, 我们进出学校时, 在门卫处都需要进行人脸识别, 验证我们是不是本校学生, 拦截一些别有用心之人. 这个人脸识别的机器, 就是一个拦截器.
Spring 中的拦截器也是一样, 能够拦截一些非法的用户请求, 保证服务的正常运行. 就像之前的图书管理系统, 只有在用户登录后, 才能访问展示图书列表的页面, 如果用户未登录就越权访问其他页面/操作其他功能, 这是非法的, 那对于用户的这些非法请求, 后端应进行拦截.
Spring 拦截器的使用, 有以下两个关键步骤:
- 定义拦截器(定义拦截器要工作的内容, 对哪些请求拦截, 对哪些请求放行) ==> 把拦截器写出来
- 注册拦截器(告诉 Spring MVC 使用这个拦截器,并指定拦截哪些路径) ==> 把拦截器用起来
1.2 定义拦截器
定义拦截器, 即实现 HandlerInterceptor 接口, 决定拦截逻辑.
在 HandlerInterceptor 中, 实现了以下方法:
其中, preHandle 在目标方法前调用, 若返回值为 true, 则对请求放行(目标方法会执行); 反之, 拦截请求(目标方法不会执行)
创建类并实现 HandlerInterceptor 后, 就可以重写这些方法, 并在方法中定义拦截逻辑:
1.3 注册拦截器
注册拦截器:实现 WebMvcConfigurer 接口, 重写 addInterceptor 方法, 调用 addInterceptors 注册拦截器.
只定义拦截器是没有用的(有了拦截器, 但没使用), 只有注册拦截器, 拦截器才能生效!!
注意: 需要给 WebMvcConfigurer 的实现类添加 @Configuration 注解, 把其交给 Spring 管理, 这样 Spring Boot 才能调用 addInterceptors
方法(Spring 自动调用), 进而将拦截器注册成功!!
如上图所示, 重写了 addInterceptors
后, 需要调用以下关键方法:
- addInterceptor => 注册拦截器
- addPathPatterns => 指定拦截路径
- excludePathPatterns => 排除某些路径
1.3.1 拦截路径
addPathPatterns 方法可以用来指定拦截路径.
excludePathPatterns 方法可以用来排除要拦截路径中的某些路径.
拦截路径 | 含义 | 举例 |
/ | 一级路径 | 能匹配 /user, /book, /login,不能匹配 /user/login |
/** | 任意级路径 | 能匹配 /user, /user/login, /user/reg |
/book/* | /book 下的一级路径 | 能匹配 /book/addBook,不能匹配 /book/addBook/1, /book/ |
/book/** | /book 下的任意级路径 | 能匹配 /book, /book/addBook, /book/addBook/2,不能匹配 /user/login |
1.4 登录校验 - 拦截器
在之前的图书管理系统中, 我们是通过 Cookie-Session 机制来校验用户是否登录, 进而进行强制登录操作. 但是之前的校验代码, 我们是放在 Controller 层完成的, 而现在, 我们就可以将代码放到拦截器中, 通过拦截器, 完成强制登录操作.
1.4.1 定义拦截器
定义拦截器时, 我们要在重写的 preHandle 方法中, 完成 Session 的验证, 当 Session 为 null 或者 Session 中没有用户信息, 那么应拦截该请求, 并设置 401 错误状态码, 并在响应中描述错误信息.
1.4.2 注册拦截器
在注册拦截器时, 指定拦截路径, 在图书管理系统中, 我们只需拦截 /book/** 下的请求路径.
1.4.3 前端代码
如果用户未登录就访问 book_list.html 页面(图书列表页面), 那么前端就会发送 Ajax 请求到后端接口, 此时后端拦截器就会检测到用户未登录, 进而拦截该请求, 并返回错误码 401.
和之前不同, 增加拦截器后, 如果用户未登录, 响应返回的是 401 错误状态码, 那么前端 success 回调函数就不会执行.
我们就需要对前端代码进行调整, 使用 error 回调函数将页面跳转到登录页面, 进行强制登录的操作:
1.5 DisPatchServlet 底层源码解析
所有进入 Spring MVC 应用程序的 HTTP 请求都会首先被 DispatcherServlet 接受, 因此如果我们通过客户端发起请求, 请求会被 DisPatchServlet 先收集到:
而 DispatcherServlet 本身就是一个 Servlet, 它间接地实现了 Servlet 接口, 因此它拥有 Servlet 的所有基本功能和生命周期:
Servlet 的生命周期如下:
- Initialization (初始化)
- Service (服务) => 会被多次调用, 每次处理一个客户端请求(核心, 真正处理客户端请求、执行业务逻辑并生成响应)
- Destruction (销毁)
而 DispatcherServlet 是一个真实的 Servlet, 因此它的核心也是 "service". 而它的 service 是靠 doDisPatch (调度方法)来实现的.
拦截器的相关逻辑(preHandle, postHandle, afterCompletion)正是包含在 doDispatch() 方法的执行流程中(doDispatch 方法中, 会对拦截器进行检测, 如果有拦截器, 则执行拦截器相关逻辑.):
1. 先获取 HandlerExecutionChain(其中包含了 Handler 和拦截器列表)
2. 依次调用 HandlerExecutionChain 中配置的拦截器的 preHandle、postHandle 和 afterCompletion 方法, 实现拦截器功能.
其中, 执行完 preHandle 后, 若 preHandle 返回的是 true, 那么说明请求被放行, 则执行目标方法(由适配器 HandlerAdapter 执行); 若 preHandle 返回的是 false, 那么说明请求被拦截, 直接返回.
常见词汇:
- Dispatch: 调度
- chain: 链
- adapter: 适配器
- apply: 应用, 执行
debug快捷键:
- F7: 进入方法
- F8: 下一行
- F9: 下一个断点
2. 统一结果返回格式
在实际的项目开发中, 我们通常将响应结果封装为同一个类型, 这样有助于前端处理结果, 提高代码的统一性.
还是以图书管理系统为例, 我们接口返回值的类型是各不统一的, 这就降低了代码的可维护性.
我们就可以借助 Spring 提供的统一结果返回格式这一功能, 将图书系统中的各接口, 返回类型都统一为 Result.
2.1 ResponseBodyAdvice
完成统一结果返回格式, 需要自定义类实现 ResponseBodyAdvice 接口, 并重写其中的方法.
实现 ResponseBodyAdvice 接口的自定义类要使用 @ControllerAdvice 注解进行标记:
- 这个注解是 Component 的派生注解, 会把这个类的 Bean 交给 Spring 来管理.
- Spring MVC 会自动识别并调用这个 Bean 中实现的 supports() 和 beforeBodyWrite() 方法, 对返回结果的格式进行统一处理.
ResponseBodyAdvice 接口中有两个方法:
- supports(): 返回值为 boolean 类型, 若返回 true, 表示对返回结果进行统一格式的处理. 若返回 false, 表示不对返回结果进行统一格式的处理.
- beforBodyWrite(): 将原始的返回值 body 转换或包装成你期望的统一响应格式.
也就是说, supports 是一个过滤器, 决定是否应用统一格式处理.
beforeBodyWrite() 是一个转换器, 当 supports 返回 true 时, 将原始结果转换为统一的格式.
其中, beforeBodyWrite 方法中的 body 参数, 就是目标方法原本的返回值, beforeBodyWrite 只是对原本返回值进行了一个包装, 将返回值进行了统一:
2.1.1 存在问题1 - 原本返回值为 String
目前来看, ResponseBodyAdvice 确实能够统一结果的返回格式, 但其实存在一个问题:
当目标方法原本的返回值为 String 类型时, 就会发生异常:
这是 Spring 底层源码导致的.
上文说了, beforeBodyWrite 会将目标方法原本的返回值进行统一处理, beforeBodyWrite 统一完后, 会将统一后的结果交给 Spring 底层的另一个方法, 但是这个方法接收的类型是 String, 而 beforeBodyWrite 统一后的结果不一定是 String 类型的(如上图返回的是一个 Result)因此类型不匹配导致异常.(注: 只有原本返回值是 String 类型时, 才会有这样的情况!!)
因此, 当原本的返回值为 String 时, 我们需要对统一后的结果再次进行处理, 将其转换为 JSON 字符串后, 再进行返回. 并且将响应的 Content-type 设置为 "application/json":
(使用 writeValueAsString 时, 可能抛出异常, 要么使用 try-catch 包一下, 要么使用 @SneakyThrows 注解声明)
注意: 只有当返回值为一个对象时(使用了 @ResponseBody 或 @RestController 的前提下), 那么 Spring 会默认将 Content-type 设置为 application-json, 但是如果我们手动使用 writeValueAsString() 将对象序列化为 JSON 字符串并返回时, 那么我们需要手动将 Content-type 设置为 application-json!!
这样, 即使原本的返回值为 String, 也不会抛出异常, 并且前端能够正确收到 JSON 数据:
2.1.2 存在问题2 - 原本返回值和统一后返回值相同
在 beforeBodyWrite 方法中, 我们是将结果统一成了 Result 类型进行返回的. 但是, 一些方法原本的返回值本来就是 Result(如 getListByPage 接口), 再进行一次封装, 反而词不达意:
因此, 我们需要在 beforeBodyWrite 中进行一下判断, 这样就没问题了:
3. 统一异常处理
在实际开发中, 我们也会对异常进行统一的处理, 向前端提供一致的错误信息响应格式.
并且, 在前面统一结果返回格式时, 其实还存在一个问题, 就是不论是否发生错误, 返回的响应结果都是 Result.success. 此外, 如果发生异常, 并且代码中没有对异常进行捕获, 那么异常就会传播到 beforeBodyWrite 方法中(此时异常信息就是方法中 body 参数), 于是会将异常信息完全暴露给前端:
那么这种处理方式是不符合逻辑的, 此时就可以对异常信息统一处理, 若出现异常, 就对异常统一进行处理, 比如: 将业务状态码设为 "FAIL", 并自定义描述错误信息, 统一进行封装后, 再返回给前端.
3.1 @ControllerAdvice + @ResponseBody / @RestControllerAdvice
进行统一异常的处理, 需要对自定义类使用 @ControllerAdvice + @ResponseBody 注解进行标记.
- @ControllerAdvic: 它使得 Spring MVC 能够识别这个类中带有 @ExceptionHandler 注解的方法, 并在应用程序抛出特定类型的异常时调用这些方法
- @ResponseBody: 表示返回的是数据, 而非 web 视图.
或者使用 @RestControllerAdvice(包含了 @ControllerAdvice + @ResponseBody)
3.2 @ExceptionHandler
在统一异常处理的自定义类中, 使用 @ExceptionHandler 来标记方法, 表示为异常处理方法.
对方法传入指定异常类型的参数, 该方法就可以捕获该类型的异常:
注: 若方法参数为异常顶级类 Exception(或者更顶级的 java.lang.Throwable), 那么该方法可以捕获所有类型的异常.
Spring MVC 的统一异常处理机制遵循“就近捕获”的原则, 会寻找最具体的能够处理该异常的 @ExceptionHandler 方法进行捕获. 也就是说, 如果程序抛出一个空指针异常, 那么这个异常会被参数为 NullPointerException 的方法所捕获, 而非参数为 Exception 的方法. 只有出现的异常没有对应的方法进行捕获时, 才会被参数为 Exception 的方法捕获.
此外, 也可以在 @ExceptionHandler 注解中, 指定要捕获的异常:
3.3 单元测试
经过统一结果返回和统一异常处理后, 响应结果和异常统一封装为 Result 类型, 因此, 前端的接收值发生了改变, 需要对前端代码进行调整.
END