一个 /error 引发两小时的 SpringMVC 源码 debug

1,356 阅读7分钟

前言

最近入职新公司,先临时接手一个认证项目,对于本人这种有代码优雅强迫症的,看到不爽的代码毫无疑问就是改!改!改!然而改完之后前端给我反馈了接口总是报 401 错误。我的内心:我草?难道是我改出 bug 了?不应该吧,这么简单的东西怎么会有 bug !于是我自己测试了下,还真是有问题,但不是我的问题,下面开始分析!

伪代码场景还原

登录接口,模拟报错

@PostMapping("/user/login")
public LoginResult login(@RequestBody LoginRequest request) {
    throw new RuntimeException("模拟登录接口报错");
}

接着贴出拦截器,如果需要认证的请求没有携带 token ,或者 redis 中查不到该 token 相关用户,就抛出异常

public class UserLoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader("token");
        if (token == null) {
            throw new UnauthorizedException("未认证或token已过期");
        } else {
            if(redis.get(token) == null) {
                throw new UnauthorizedException("未认证或token已过期");
            }
            //...将token和用户信息设置到 ThreadLocal
        }
        return true;
    }
}

拦截器配置

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserLoginInterceptor())
                .excludePathPatterns("/user/login")
                .addPathPatterns("/**");
    }
}

这个项目是通过拦截器中获取 token 去 Redis 查用户信息放到 ThreadLocal 里面的,由于一个请求从 Controller → Service → Mapper 线程 ID 都是一致的,这样一条请求链都能从这个 ThreadLocal 里面拿到当前登录用户信息。可以看到 /user/login 是被拦截器放行的,然而当这个请求的 Controller 报错,预期的 message 信息应该是 模拟登录接口报错,然而运行时报的居然是下面未认证的错,这说明我们的请求走到了拦截器

{
    "path": "/error",
    "message": "com.yinshan.auth.core.exception.UnauthorizedException: 未认证或token已过期",
    "error": "Unauthorized",
    "status": 401,
    "timestamp": "2021-09-22T14:03:39.986559500"
}

当然这个错误信息格式是我自己处理过的,这个不重要,重点是我在登录接口中报 500 的错,为啥变成了拦截器中的 401 未认证。

调试分析

废话少说,直接 debug 走起,在抛异常的代码上打个断点,再把拦截器中打个断点

image.png

image.png

结果在登录接口按下 F9 之后,断点确实走到了拦截器中,

说实话我当时真的是这个表情,这特么已经被拦截器放行的接口报错关拦截器什么事?然而在调试面板仔细一看 preHandle 这个方法的请求参数详细信息发现了猫腻。

image.png

图中箭头指向是很重要的信息:

  • 是当前请求的上下文,正常请求走拦截器时是没有这个上下文
  • 请求的分发类型,正常请求的值是 REQUEST
  • 特别显眼的是这个请求资源 uri,根本不是我请求的 /user/login,而是一个 /error

看到这里大致就明白了,这个断点走到拦截器,不是因为 /user/login 这个请求,而是另一个 /error 请求。那么这个 /error 是怎么来的?由于图中的 TomcatEmbededContext 上下文是 SpringBoot 内嵌的 Tomcat 中的一个类,我猜这个请求应该是 SpringMVC 控制器遇到未处理的报错重新内部发起的一个 /error 请求。

也许你会疑惑,这不是找到问题了吗?好像挺快的呀,你为啥搞了两个小时呢?因为我菜啊! 我调试的时候压根就没关心这个参数是啥,而是一步一步 F8 → F7 → F8 → F7 ...... 过五关斩六将。。。最后调试到了 DispatchServlet 的时候我才反应过来,这特么怎么跑到请求转发了,最后终于明白了,人都麻了。

查询官方文档

果然在 SpringMVC 的官方文档找到了说明

image.png

官网说的很清楚了,如果异常没有被默认的异常处理器处理,那么 Servlet 容器将会用 DispatchServlet 分派一个 /error 请求,也可以对 /error 请求进行定制化处理,详情可以参考 SpringMVC 官方文档

具体原因

SpringMVC 的控制器报错之后服务器会弄一个 /error 的请求,由于我们的拦截器没有放行这个 /error 请求,所以会在 DispatchServlet 中执行该请求的拦截器(我突然想起两年前还写过自定义 SpringBoot 异常页面,就是处理的 /error 请求)

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    //...
    //判断并执行拦截器的 preHandle()
    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
       return;
    }

上面是 DispatchServletdoDispatch 部分源码,我相信大多数人对于 doDispatch 的理解都停留在为了面试,背 SpringMVC 执行流程的时候。其实网上对于 SpringMVC 执行流程画的图都是几个关键节点,并没有这么细致,如果说没有真正带着问题调试过这段源码,那么大概率也是不懂这个问题的。

解决方案

明白了问题的原因,解决就很简单了。只要在我们自定义的认证拦截器中排除掉对 /error 的拦截即可

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new UserLoginInterceptor())
            .excludePathPatterns("/error").addPathPatterns("/**");
}

谈谈拦截器

其实上面的问题很大一部分都是因为对拦截器没有真正的理解,只是知道它能够拦截一个请求,而没有研究过它在什么阶段拦截,在 SpringMVC 中又是怎么去实现的。那么接下来深入分析一下拦截器

拦截器与过滤器的使用范围

查看 Filter 接口源码就能发现,它是 javax.servlet 包下的,而 HandlerInterceptororg.springframework.web.servlet 包下的,拦截器是 SpringMVC 实现的,实际上它只是一个或者多个 Java 类组合实现拦截而已,和 web 应用没有必然联系。这意味着过滤器只能在 web 应用中使用,而拦截器可以用在任何可以用 SpringSpringMVC 的地方,比如桌面应用程序。

拦截器和过滤器的执行顺序&执行流程

过滤器的执行是在请求到达 Servlet 之前通过 ApplicationFilterChain.doFilter() 进行链式调用的,在 doFilter() 内部获取到下一个过滤器实例,执行过滤方法,它的执行顺序是 filter1 → ApplicaitonFilterChain.doFilter() → filter2 → ApplicationFilterChain.doFilter() → filter3 → ......

如下图

image.png

而拦截器的执行是请求到达 DispatchServlet 之后针对 Controller 方法执行前、执行后做的一些事情,如下图,这里的过滤器链就是上面那张图

image.png

很明显 preHandle() 才是拦截的关键,只有它是在请求到达 Controller 目标方法之前执行的,该方法通过返回 true/false 决定请求是否需要被拦截。

doDispatch 内部对拦截器的处理部分源码

我们都知道 DispatchServletdoDispatch() 方法是处理所有请求的,内部和拦截器相关的代码如下

//调用 Controller 目标方法前执行拦截器的 preHandle()
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
    return;
}
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());//反射调用 Controller 目标方法
/**
 * ...省略
 * */
mappedHandler.applyPostHandle(processedRequest, response, mv);//Controller 目标方法执行完后调用拦截器 postHandle()
//请求完成之后执行拦截器的 afterCompletion()
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);

其实真正带着问题调试过源码的话,根本就不需要背 SpringMVC 的执行流程面试题啦~~~ 我就背不下来,但是从源码调试过程中,我已经很清楚了 DispatchServlet 在请求转发过程中都做了那些事情,结合之前说过的 参数校验神器 hibernate-validator 配合统一异常处理 自然也明白了 SpringMVC 是怎样实现请求参数的解析、转换的。

结语

遇到问题不要慌,源码调试没有那么难,我觉得带着问题去看源码更能够让印象更深刻。来新公司不到一个月,我已经带着问题看了好几次源码了......正好赶上换技术组件的大版本,总是有各种奇奇怪怪的问题。

平时多看看框架、技术组件的官方文档真的是一个非常好的习惯,不要总局限于某些视频教程。多读官方文档,才能发现组件可能存在的问题,出现问题的原因。