一. 拦截器
1.1 什么是拦截器
拦截器是Spring框架提供的核心功能之一,主要用来拦截客户的请求,在指定方法前后,根据业务需要执行预先设定的代码.
比如博主现在正在编写博客,要访问的url是editor.html页面,如果我没有进行登陆操作的话,还能进入到这个页面吗?
答案肯定是否定的,因为服务器有session机制,会检查我的信息.访问其他的页面也是一样,比如我要对某篇博文点赞或者评论,都是不可以的.
我们学过MVC后知道,针对每个请求,都需要编写一个方法来返回响应结果,这样的话每个方法都要检查一遍session里是否有我的登录信息.
我们简单写个代码来演示一下~
//用户进行登陆的时候,LoginController会存储session信息
//用户发送的每个请求,都要先进行session的检验
@RestController
@RequestMapping("/login")
public class LoginController {
@RequestMapping("/login")
public Boolean login(String name, String password, HttpSession session){
if(!StringUtils.hasLength(name)||!StringUtils.hasLength(password)){
return false;
}
if(name.equals("admin")&&password.equals("admin")){
session.setAttribute("user","admin");
return true;
}
return false;
}
}
@RestController
@RequestMapping("/welcome")
public class WelcomeController {
@RequestMapping("/sayHi")
public String sayHi(String name, HttpSession session){
if(session.getAttribute("user")==null){
return null;
}
return "Hi,"+name;
}
@RequestMapping("/sayHello")
public String sayHello(String name,HttpSession session){
if(session.getAttribute("user")==null){
return null;
}
return "Hello,"+name;
}
}
有亿点点经验的你们肯定会想,能不能把这个检验单独设置成一个方法,然后统一调用这个方法?
SpringBoot也是这样想的,不仅如此,我们还可以不用手动去调这个方法,它会帮助我们统一进行拦截操作.
1.2 拦截器的使用
拦截器的实现分为两步:
- 定义拦截器
- 注册配置拦截器
** 定义拦截器: 实现HandlerInterceptor接口,并重写所有方法.**
@Slf4j//方便日志打印
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("登录拦截器,目标方法执行前拦截...");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("登陆拦截器,目标方法执行后拦截...");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
log.info("最后执行");
}
}
- preHandle方法: 在目标方法执行前调用,如果返回值为false,则拦截成功,不执行目标方法;如果返回值为true,则不进行拦截.
- postHandle方法: 在目标方法执行后调用
- afterCompletion方法: 视图渲染完毕后调用(后端几乎不涉及视图,因此我们不必理会)
注册配置拦截器: 实现WebMVCConfigurer接口,并重写****addInterceptors方法
@Configuration//将这个对象交给Spring管理
public class Config implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor());
}
}
为了演示方便,简单修改一下WelcomeController类的方法.
@Slf4j
@RestController
@RequestMapping("/welcome")
public class WelcomeController {
@RequestMapping("/sayHi")
public String sayHi(String name, HttpSession session){
log.info("执行了sayHi方法");
return "Hi,"+name;
}
@RequestMapping("/sayHello")
public String sayHello(String name,HttpSession session){
log.info("执行了sayHello方法");
return "Hello,"+name;
}
}
现在我们访问这个类的sayHi接口.
来看一下后台的打印日志.
如果将preHandle方法的返回值改为false: 从打印日志中看到.目标方法并没有执行
前面提到过,拦截器可以对指定方法进行拦截,我们该如何指定这些目标方法呢?
需要借助两个方法:
- addPathPatterns: 添加拦截路径
- excludePathPatterns: 排除拦截路径
比如我们要实现用户的登陆拦截,login()方法的路由肯定就不能拦截了.
@Configuration
public class Config implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")//拦截任意级的全部路径
.excludePathPatterns("/login/login");//排除/login/login路径
}
}
现在我们访问"/login/login"路由.
可以看到,前端接收到了返回的数据,控制台上没有打印执行拦截方法的日志.
下面是一些常见拦截路径设置:
拦截路径含义举例/*拦截一级路径可以拦截/login,/user...这些一级路径/**拦截任意级路径可以拦截/login,/login/login等全部路径/user/*拦截/user下的一级路径/user/**拦截/user下的所有路径
1.3 拦截器执行流程
看完刚才的代码演示,相信童鞋们也大概猜到了拦截器是在什么时候发挥作用的,现在来详细讲解一下~
没有使用拦截器时,一个请求的处理流程如下图
添加拦截器之后,执行Controller层的方法之前,会先执行preHandle()方法;
执行完Controller层的方法之后,会执行postHandle()方法,最后执行afterCompletion()方法.
1.4 拦截器的实现(源码分析)
因为涉及到Spring的源码,因此这部分比较晦涩(实际上博主也比较懵逼,只能简单介绍一下自己知道的).
先来启动SpringBoot项目,然后我们随便访问一个路由,会发现SpringBoot初始化了DispatcherServlet类.
回顾一下Servlet的生命周期:
init()---->doService()---->destory()
下面来看看这个类的init()方法.
上面标黄的部分是我们要关注的重点内容,先按下不表,让我们来看看拦截器是在什么时候生效的.
这就要借助debug来看方法的调用关系了,为了完整的走完整个流程,我们先让preHandle()方法返回true,即不对目标方法进行拦截.
现在来访问/welcome/sayHi路由,根据刚才的流程图,Spring首先调用的是preHandle()方法.
按快捷键shift+f8,就可以看到调用preHandle()的上一层方法了.于是就进入了下面这个页面...
虽然没有完全读懂,但是可以猜到,applyPreHandle方法会遍历所有的preHandle方法并执行,其他两个方法也是同样.
再次shift+f8,我们就进入了下面的这个页面
这次调用的是DispatcherServlet类的doDispatch方法.上图标红的三个方法,根据方法名也不难猜到,SpringBoot内置的DispatcherServlet对象按照顺序执行了preHandle() ,目标方法.postHandle()方法.
再次按shift+f8,这次我们执行了sayHi方法
接着返回上一层,发现看不懂,那就接着返回上一层...然后我们就可以看见下面这个页面,是不是很眼熟?
再往上跳几层~我们又来到了DispatcherServlet类的doDispatch方法
接着shift+f8,这次我们要执行postHandle方法.
往上跳一层,正如刚才预料,Spring使用applyPostHandle方法遍历所有被注册过的拦截器的postHandle()
接着往上走~我们又回到了doDispatch方法.
接着按shift+f8,我们这次要过的是afterCompletion()的调用过程.可以看到,也是由SpringBoot进行所有afterCompletion方法的遍历之后调用的.
接着往上走我们又来到了doDispatch方法
经过上面一顿猛如虎的操作,同学们肯定更能理解拦截器的流程了.
再来看看下面这张图片中标黄的三个方法,很容易猜到,第一个是负责路由映射的,第二个是适配器,第三个是负责异常处理的
二. 适配器模式
适配器模式,也叫包装器模式,是指将一个类的接口,转换为用户期望的另一个接口.
如果我们去国外,肯定少不了用一个插头转换器,否则无论是插头还是电压,国外的都与国内不匹配.
这就是一种适配器,将我们的电器和国外的插头匹配到一起.
下面介绍几个适配器模式里的概念:
- Target: 目标接口(一般是抽象类或者接口),用户希望直接使用的接口
- Adaptee: 适配者,但是与Target不兼容
- Adapter: 适配器类,此模式的核心,通过继承或引用适配者的对象,把适配者转换为目标接口
- client: 需要使用此适配器的对象
我们知道,Slf4j 采用了门面模式,为程序猿提供了日志打印的接口,但是具体的实现是 Log4j(Spring默认)做的.
Slf4j 和 Log4j 的兼容就采用了适配器模式.
现在我们写个代码模拟一下.
//适配者,可以真正工作的类
public class MyLog4j {
void log(String message){
System.out.println("Log4j打印日志: "+message);
}
}
//用户希望使用的接口
public interface MySlf4j {
void log(String message);
}
//适配器,帮助进行两个类之间的转换
public class SlfLogAdaptor implements MySlf4j{
private MyLog4j logger=new MyLog4j();
@Override
public void log(String message) {
logger.log(message);
}
}
//用户调用Slf4j接口
public class Main {
public static void main(String[] args) {
MySlf4j slf4j=new SlfLogAdaptor();
slf4j.log("slf4j接口打印的日志");
}
}
浅浅总结一句: 门面模式是为了实现低耦合,当底层实现的类发生变化时不需要修改用户的代码;
适配器模式是为了实现用户接口和真正实现的类之间的兼容.门面模式的实现通常需要适配器.
二. 统一数据返回格式
2.1 为啥要统一返回格式
假设要设计一个12306系统,前后端查询余票的接口按照如下方式定义:
前端: searchLeft?start=12&end=68
后端: num=余票数目
这样就会产生一个问题: 如果起点和终点站之间没有车次,会返回0;如果起点和终点站之间票卖光了,也会返回0.但是面对第一种情况,前端给客户的响应应该是: 更换起点/终点站,面对第二种情况,前端给客户的响应应该是: 尝试购买候补票.
所以后端返回的信息中,起码应当包含业务码和实际返回对象.
什么是业务码呢?
业务码是由前后端程序猿自定义的,不同的值代表不同的响应结果,比如1表示查询成功,2表示查询失败,没有这个车次...
要注意和http状态码区分开,业务码是被包含在返回的响应中的,首先要响应成功才会有业务码的传递. http状态码是http协议规定的,3XX表示重定向,4XX表示客户端这边有问题,5XX表示服务器有问题.
除此之外,约定一种统一的返回格式,可以减少前后端程序猿的沟通成本,双方只要按照这一种格式实现就可以了.
2.2 快速上手
让我们先把之前的代码完善一下,更改一下登陆校验.
//创建一个实体类User
@Data
public class User {
private String name;
private String password;
}
@RestController
@RequestMapping("/login")
public class LoginController {
@RequestMapping("/login")
public Boolean login(User user, HttpSession session){
String name=user.getName();
String password=user.getPassword();
if(!StringUtils.hasLength(name)||!StringUtils.hasLength(password)){
return false;
}
if(name.equals("admin")&&password.equals("admin")){//用户名为admin且密码为admin时才端登陆成功并设置session
session.setAttribute("user",user);
return true;
}
return false;
}
}
下面是拦截器的部分代码修改
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("登录拦截器,目标方法执行前拦截...");
HttpSession session=request.getSession();
if(session.getAttribute("user")!=null) {
return true;
}
return false;
}
现在给UserController类定义两个方法.
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/sayHello")
public String sayHello(User user){
log.info("执行sayHello,user: {}",user);
if(user==null){
return "参数错误,请检查入参!";
}
return "Hi,"+user.getName();
}
@RequestMapping("/getUser")
public User logUser(HttpSession session){
log.info("执行logUser");
return (User)session.getAttribute("user");
}
}
可以看到,sayHi方法返回的是String类型的数据,logUser方法返回的是User对象.
因此我们需要一个Result类来作为统一的返回格式.
@Data
public class Result<T> {
private int code;//业务码 1-响应成功 0-响应失败,也可以定义成枚举类型,此处不再演示
private String message;//业务码说明
private T data;
public static <T> Result<T> success(T data){
Result result=new Result();
result.setCode(1);
result.setData(data);
return result;
}
public static <T> Result<T> fail(String message){
Result result=new Result();
result.setCode(0);
result.setMessage(message);
return result;
}
}
接下来就是重头戏了!我们需要把所有方法返回的数据转换成Result类中的data并返回这个result给前端.
有两个步骤: 加上@ControllerAdvice注解, 实现 ResponseBodyAdvice 接口
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;//相当于一个开关,只有返回true时才能进行统一格式的返回
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
return Result.success(body);//返回Result类封装后的数据
}
}
现在我们使用Postman来模拟一下用户请求
先来访问/user/sayHello路由,可以看到,没有返回任何数据,因为被拦截器拦截了.
所以我们要先进行登录操作.
可以看到,login方法返回的true被封装到Result对象中,现在来检验一下其他接口.
正确拿到了session中存储的user信息
再来测试一下 sayHello接口,可以看到,后端向你抛出了一个异常...
2.3 三大问题
仔细观察我们定义的ResponseAdvice类,可以发现两个大问题:
- 无论返回什么数据,返回的Result的业务码都是1(success)
- 如果body是String对象的话,后端会抛出异常
- 如果body本身就是Result对象的话,beforeBodyWrite会进行二次封装
先来解决第三个问题
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if(body instanceof Result){
return body;
}
return Result.success(body);//返回Result类封装后的数据
}
再来看看第二个问题的原因,来看一下异常信息.
我们先点开异常异常第一次被抛出的位置.
那么该怎么解决呢?回想一下我们借助是如何借助json传递对象的.因为Spring内置了jackson,我们也不需要导入jar包了.
ObjectMapper mapper=new ObjectMapper();
@SneakyThrows//会帮助生成try-catch
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if(body instanceof Result){
return body;
}
if(body instanceof String){
return mapper.writeValueAsString(Result.success(body));
}
return Result.success(body);//返回Result类封装后的数据
}
}
现在重新访问一下sayHello接口.虽然页面有点不一样,但还是json格式的数据.
至于第一个问题如何解决,就要移步到下一小节了.
三. 统一异常处理
3.1 为啥要进行统一异常处理
如果后端抛出了异常(见下图)
- 对于用户来讲,体验感是非常不好的
- 用户就会看到后端代码的调用,涉及到相关的商业机密,是不可以泄露出去的
这种情况下,我们希望用户看到的是一个不包含任何代码信心的页面,而不是一连串的异常.
3.2 统一异常处理
需要借助**@ControllerAdvice 和 @ExceptionHandler**来实现
为了测试方便,专门制造一个抛异常的ExceptionController
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@RequestMapping("/divide")
public int divide(){
int num=10/0;
return num;
}
@RequestMapping("/getLength")
public int getLength(){
String s=null;
return s.length();
}
@RequestMapping("/getChar")
public char getChar(){
char[] arr={1,2,3};
return arr[4];
}
}
捣蛋鬼已经准备好,现在来设置异常处理器
@ControllerAdvice
@Slf4j
public class ErrorHandler{
@ExceptionHandler
public String handle(Exception e){
log.info("发生Exception,e",e);
return "后端异常";
}
}
分别访问一下有异常的三个方法.
我们发现,还是可以看到异常的打印信息,并且此时页面显示的是404,说明没找到某个页面.
此时我们需要再加一个注解@ResponseBody
再次访问会抛异常的方法.
同学们肯定已经感知到,业务码一直是1肯定是不对的,因为1表示是响应成功
再次修改异常处理器的代码
@ControllerAdvice
@Slf4j
@ResponseBody
public class ErrorHandler{
@ExceptionHandler
public Result handle(Exception e){
log.info("发生Exception,e",e);
return Result.fail("后端异常");
}
}
这就达到了我们想要的效果,当然,前端可以对数据进行一定的加工,让用户的体验感更好一些.
实际上,我们可以在异常处理器中定义多个方法,分别用来捕捉不同的异常.
@ControllerAdvice
@Slf4j
@ResponseBody
public class ErrorHandler{
@ExceptionHandler
public Result handle(Exception e){
log.info("发生Exception,e",e);
return Result.fail("后端异常");
}
@ExceptionHandler
public Result handle(NullPointerException e){
log.info("发生NullPointerException,e",e);
return Result.fail("NullPointerException");
}
@ExceptionHandler
public Result handle(ArithmeticException e){
log.info("发生ArithmeticException,e",e);
return Result.fail("ArithmeticException");
}
}
肯定有同学会疑惑,这样的话Exception e不是就把全部异常捕捉到了吗,还会用到其他的方法吗?
这些方法的执行是有先后顺序的,SpringBoot会找一个参数最匹配的方法来执行.
不妨来试验一下.
版权归原作者 只会敲代码。 所有, 如有侵权,请联系我们删除。