前言
最近入职新公司,先临时接手一个认证项目,对于本人这种有代码优雅强迫症的,看到不爽的代码毫无疑问就是改!改!改!然而改完之后前端给我反馈了接口总是报 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 走起,在抛异常的代码上打个断点,再把拦截器中打个断点
结果在登录接口按下 F9 之后,断点确实走到了拦截器中,
说实话我当时真的是这个表情,这特么已经被拦截器放行的接口报错关拦截器什么事?然而在调试面板仔细一看 preHandle 这个方法的请求参数详细信息发现了猫腻。
图中箭头指向是很重要的信息:
- 是当前请求的上下文,正常请求走拦截器时是没有这个上下文
- 请求的分发类型,正常请求的值是 REQUEST
- 特别显眼的是这个请求资源 uri,根本不是我请求的 /user/login,而是一个 /error
看到这里大致就明白了,这个断点走到拦截器,不是因为 /user/login 这个请求,而是另一个 /error 请求。那么这个 /error 是怎么来的?由于图中的 TomcatEmbededContext 上下文是 SpringBoot 内嵌的 Tomcat 中的一个类,我猜这个请求应该是 SpringMVC 控制器遇到未处理的报错重新内部发起的一个 /error 请求。
也许你会疑惑,这不是找到问题了吗?好像挺快的呀,你为啥搞了两个小时呢?因为我菜啊! 我调试的时候压根就没关心这个参数是啥,而是一步一步 F8 → F7 → F8 → F7 ...... 过五关斩六将。。。最后调试到了 DispatchServlet 的时候我才反应过来,这特么怎么跑到请求转发了,最后终于明白了,人都麻了。
查询官方文档
果然在 SpringMVC 的官方文档找到了说明
官网说的很清楚了,如果异常没有被默认的异常处理器处理,那么 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 的拦截即可
@Override
public 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 → ......
如下图
而拦截器的执行是请求到达 DispatchServlet 之后针对 Controller 方法执行前、执行后做的一些事情,如下图,这里的过滤器链就是上面那张图
很明显 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 是怎样实现请求参数的解析、转换的。
结语
遇到问题不要慌,源码调试没有那么难,我觉得带着问题去看源码更能够让印象更深刻。来新公司不到一个月,我已经带着问题看了好几次源码了......正好赶上换技术组件的大版本,总是有各种奇奇怪怪的问题。
平时多看看框架、技术组件的官方文档真的是一个非常好的习惯,不要总局限于某些视频教程。多读官方文档,才能发现组件可能存在的问题,出现问题的原因。