java小悠 发表于 2021-10-9 13:59:45

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

媒介

最近入职新公司,先临时接手一个认证项目,对于本人这种有代码优雅强迫症的,看到不爽的代码毫无疑问就是改!改!改!然而改完之后前端给我反馈了接口总是报 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;    }}拦截器配置
@Configurationpublic 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 走起,在抛非常的代码上打个断点,再把拦截器中打个断点
https://p9.toutiaoimg.com/large/pgc-image/0815676e0ccf45ec9832576b18d9361e
https://p26.toutiaoimg.com/large/pgc-image/8c782a68e6194a48ac40fb4c0a3fdfce
结果在登录接口按下 F9 之后,断点确实走到了拦截器中,
https://p6.toutiaoimg.com/large/pgc-image/6ebf9548a1634ab697430e0a14ebacb4
说实话我当时真的是这个心情,这特么已经被拦截器放行的接口报错关拦截器什么事?然而在调试面板仔细一看 preHandle 这个方法的请求参数详细信息发现了猫腻。
https://p3.toutiaoimg.com/large/pgc-image/34a892ef217f4512bfb37744938075c6
图中箭头指向是很重要的信息:

[*]是当前请求的上下文,正常请求走拦截器时是没有这个上下文
[*]请求的分发类型,正常请求的值是 REQUEST
[*]特别显眼的是这个请求资源 uri,根本不是我请求的 /user/login,而是一个 /error
看到这里大抵就明白了,这个断点走到拦截器,不是因为 /user/login 这个请求,而是另一个 /error 请求。那么这个 /error 是怎么来的?由于图中的 TomcatEmbededContext 上下文是 SpringBoot 内嵌的 Tomcat 中的一个类,我猜这个请求应该是 SpringMVC 控制器碰到未处理的报错重新内部发起的一个 /error 请求。
大概你会疑惑,这不是找到问题了吗?好像挺快的呀,你为啥搞了两个小时呢?因为我菜啊! 我调试的时间压根就没关心这个参数是啥,而是一步一步 F8 → F7 → F8 → F7 ...... 过五关斩六将。。。末了调试到了 DispatchServlet 的时间我才反应过来,这特么怎么跑到请求转发了,末了终于明白了,人都麻了。
查询官方文档

果然在 SpringMVC 的官方文档找到了说明
https://p6.toutiaoimg.com/large/pgc-image/52616bd263444e06b30b150ff86d6fef
官网说的很清楚了,如果非常没有被默认的非常处理器处理,那么 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;    }上面是 DispatchServlet 的 doDispatch 部分源码,我相信大多数人对于 doDispatch 的理解都停顿在为了口试,背 SpringMVC 执行流程的时间。其实网上对于 SpringMVC 执行流程画的图都是几个关键节点,并没有这么细致,如果说没有真正带着问题调试过这段源码,那么大概率也是不懂这个问题的。
解决方案

明白了问题的缘故原由,解决就很简朴了。只要在我们自定义的认证拦截器中排除掉对 /error 的拦截即可
@Overridepublic void addInterceptors(InterceptorRegistry registry) {    registry.addInterceptor(new UserLoginInterceptor())            .excludePathPatterns("/error").addPathPatterns("/**");}谈谈拦截器

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

查看 Filter 接口源码就能发现,它是 javax.servlet 包下的,而 HandlerInterceptor 是 org.springframework.web.servlet 包下的,拦截器是 SpringMVC 实现的,现实上它只是一个大概多个 Java 类组合实现拦截而已,和 web 应用没有必然接洽。这意味着过滤器只能在 web 应用中使用,而拦截器可以用在任何可以用 Spring 和 SpringMVC 的地方,比如桌面应用程序。
拦截器和过滤器的执行顺序&执行流程

过滤器的执行是在请求到达 Servlet 之前通过 ApplicationFilterChain.doFilter() 举行链式调用的,在 doFilter() 内部获取到下一个过滤器实例,执行过滤方法,它的执行顺序是 filter1 → ApplicaitonFilterChain.doFilter() → filter2 → ApplicationFilterChain.doFilter() → filter3 → ......
如下图
https://p3.toutiaoimg.com/large/pgc-image/e3ad84ae9088447b87bcacbb54523e1a
而拦截器的执行是请求到达 DispatchServlet 之后针对 Controller 方法执行前、执行后做的一些事情,如下图,这里的过滤器链就是上面那张图
https://p3.toutiaoimg.com/large/pgc-image/7737c74e00ad4e4aa762000356ac25b1
很明显 preHandle() 才是拦截的关键,只有它是在请求到达 Controller 目标方法之前执行的,该方法通过返回 true/false 决定请求是否需要被拦截。
doDispatch 内部对拦截器的处理部分源码

我们都知道 DispatchServlet 的 doDispatch() 方法是处理所有请求的,内部和拦截器相关的代码如下
//调用 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 是怎样实现请求参数的解析、转换的。
结语

碰到问题不要慌,源码调试没有那么难,我觉得带着问题去看源码更能够让印象更深刻。来新公司不到一个月,我已经带着问题看了好频频源码了......正好赶上换技术组件的大版本,总是有各种奇希奇怪的问题。
平时多看看框架、技术组件的官方文档真的是一个非常好的习惯,不要总局限于某些视频教程。多读官方文档,才能发现组件可能存在的问题,出现问题的缘故原由。

作者:暮色妖娆丶
链接:https://juejin.cn/post/7013636541566156813

摄氏7 发表于 2021-10-9 19:59:21

应该还有其他问题,为啥明明是调用的login请求,但是收到的是error?我之前遇到过用的shiro框架做登录验证组建也是这样,后端都是api接口,但是设置了退出后的page什么的导致的,后面把这些配置都去掉之后,就没这个问题了

ZzzzzZzzZ 发表于 2021-10-9 19:26:50

不错

我是董郎啊 发表于 2021-10-10 09:32:39

转发了
页: [1]
查看完整版本: 一个 /error 引发两小时的 SpringMVC 源码 debug