0


SpringBoot统一功能处理

一. 拦截器

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;
    }
}

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

有亿点点经验的你们肯定会想,能不能把这个检验单独设置成一个方法,然后统一调用这个方法?

SpringBoot也是这样想的,不仅如此,我们还可以不用手动去调这个方法,它会帮助我们统一进行拦截操作.

1.2 拦截器的使用

拦截器的实现分为两步:

  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("最后执行");
    }
}

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

  • preHandle方法: 在目标方法执行前调用,如果返回值为false,则拦截成功,不执行目标方法;如果返回值为true,则不进行拦截.
  • postHandle方法: 在目标方法执行后调用
  • afterCompletion方法: 视图渲染完毕后调用(后端几乎不涉及视图,因此我们不必理会)

注册配置拦截器: 实现WebMVCConfigurer接口,并重写****addInterceptors方法

@Configuration//将这个对象交给Spring管理
public class Config implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor());
    }
}

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

为了演示方便,简单修改一下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;
    }
}

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

现在我们访问这个类的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路径
    }
}

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

现在我们访问"/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方法


经过上面一顿猛如虎的操作,同学们肯定更能理解拦截器的流程了.

再来看看下面这张图片中标黄的三个方法,很容易猜到,第一个是负责路由映射的,第二个是适配器,第三个是负责异常处理的

二. 适配器模式

适配器模式,也叫包装器模式,是指将一个类的接口,转换为用户期望的另一个接口.

如果我们去国外,肯定少不了用一个插头转换器,否则无论是插头还是电压,国外的都与国内不匹配.

这就是一种适配器,将我们的电器和国外的插头匹配到一起.

下面介绍几个适配器模式里的概念:

  1. Target: 目标接口(一般是抽象类或者接口),用户希望直接使用的接口
  2. Adaptee: 适配者,但是与Target不兼容
  3. Adapter: 适配器类,此模式的核心,通过继承或引用适配者的对象,把适配者转换为目标接口
  4. 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类,可以发现两个大问题:

  1. 无论返回什么数据,返回的Result的业务码都是1(success)
  2. 如果body是String对象的话,后端会抛出异常
  3. 如果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 为啥要进行统一异常处理

如果后端抛出了异常(见下图)

  1. 对于用户来讲,体验感是非常不好的
  2. 用户就会看到后端代码的调用,涉及到相关的商业机密,是不可以泄露出去的

这种情况下,我们希望用户看到的是一个不包含任何代码信心的页面,而不是一连串的异常.

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会找一个参数最匹配的方法来执行.

不妨来试验一下.

标签: spring java 后端

本文转载自: https://blog.csdn.net/m0_72536301/article/details/134726751
版权归原作者 只会敲代码。 所有, 如有侵权,请联系我们删除。

“SpringBoot统一功能处理”的评论:

还没有评论