SpringMVC工作流程详解:从请求到响应,用流程图定位问题
SpringMVC 的工作流程大概是 Java 后端面试里出现频率最高的问题也是很多开发者“背得下来却用不上”的问题。我在实际排查问题的时候发现真正能把流程图当成调试地图的人处理起 404、400、参数丢失、视图路径错误这类问题会快很多而只背了一个大概流程的人遇到异常往往只能靠猜。这篇文章我不会按套路复述一遍流程而是把从请求进到 Servlet 容器开始一直到响应返回这几大步里每一个关键节点的作用、配套组件和常见故障点拆开讲适合正在学 SpringMVC 的初学者、准备面试的人以及被各种 Web 层问题折磨过的后端开发者。看完之后你会发现流程不是用来背的是用来定位问题的。1. 请求还没进 Controller 前DispatcherServlet 是怎么被初始化的1.1 先分清两个容器根容器和 DispatcherServlet 自己的容器很多人背流程时忽略了一个前提DispatcherServlet 本身是一个 Servlet它必须被 Servlet 容器加载并初始化之后才有后续的 HandlerMapping、 HandlerAdapter 这些事。在传统的 XML 配置时代web.xml 里通常有两段配置一段是 ContextLoaderListener一段是 DispatcherServlet。context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-root.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingContextLoaderListener 负责创建根 Spring 容器里面一般是 Service、DAO、数据源这些业务层组件DispatcherServlet 又会创建一个属于它自己的 WebApplicationContext也就是子容器里面扫描 Controller、HandlerMapping、HandlerAdapter 这些 Web 层组件。子容器能够访问父容器的 Bean反过来父容器看不到子容器的 Bean。这个父子关系是很多人一开始忽略的坑Controller 里注入 Service 报 NoSuchBeanDefinitionException十有八九是 Service 扫描在了子容器范围之外或者是根容器里压根没有这个 Bean。现代基于注解的配置会把这一切简化成继承 AbstractAnnotationConfigDispatcherServletInitializer但底层机制没有变getRootConfigClasses 返回的是根容器配置getServletConfigClasses 返回的是 DispatcherServlet 子容器配置getServletMappings 决定了这个 DispatcherServlet 拦截哪些请求。public class MyWebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { Override protected Class?[] getRootConfigClasses() { return new Class?[]{RootConfig.class}; } Override protected Class?[] getServletConfigClasses() { return new Class?[]{WebConfig.class}; } Override protected String[] getServletMappings() { return new String[]{/}; } }1.2 initStrategies 到底初始化了哪些“装备”DispatcherServlet 继承自 FrameworkServlet。FrameworkServlet 在容器刷新完成之后会调用 DispatcherServlet 的 onRefreshonRefresh 最终执行 initStrategies。这个方法一次性初始化了流程里后面所有要用到的组件集合protected void initStrategies(ApplicationContext context) { initMultipartResolver(context); initLocaleResolver(context); initThemeResolver(context); initHandlerMappings(context); initHandlerAdapters(context); initHandlerExceptionResolvers(context); initRequestToViewNameTranslator(context); initViewResolvers(context); initFlashMapManager(context); }注意这些初始化的结果都是 List 或者单个组件存放在 DispatcherServlet 的成员变量里。比如 handlerMappings 是一个 ListHandlerMappinghandlerAdapters 是 ListHandlerAdapterhandlerExceptionResolvers 是 ListHandlerExceptionResolver。这也意味着你可以在配置里手动声明自定义的 HandlerMapping、HandlerAdapter、ViewResolverSpringMVC 会把它们追加进集合并在流程中按 order 顺序逐个尝试。实际的请求到达时Servlet 容器调用 DispatcherServlet 的 service 方法FrameworkServlet 重写了 doGet、doPost 等方法统一进入 processRequest再由 processRequest 转到 doService最后进入 doDispatch。doDispatch 才是整个 SpringMVC 工作流程的主战场后面所有环节都在这个方法里串联起来。想调试 SpringMVC 问题时第一行断点打在这里基本不会错。2. HandlerMappingURL 是怎么和某个具体方法挂上钩的2.1 映射表的注册规则与匹配优先级doDispatch 的第一件事是调用 getHandler(processedRequest)这个方法会遍历 DispatcherServlet 持有的 handlerMappings 集合返回第一个找到的能匹配当前请求的 HandlerExecutionChain。默认情况下SpringMVC 注册的是 RequestMappingHandlerMapping它在一开始启动时就会扫描容器中所有标注了 Controller 的类把类上 RequestMapping 的路径和方法上 RequestMapping 的路径拼接起来得到一个完整的映射条件。这里有一个很多资料不会细讲但实际排查时很关键的点一个请求能不能找到对应方法不只是看路径是否对得上。RequestMappingInfo 里还包含请求方法、参数条件、请求头条件、produces、consumes。举个例子你定义了 PostMapping(/user) 但用 GET 请求访问HandlerMapping 是能找到这个映射的但匹配结果会包含“方法条件不满足”最终由后续流程抛出 405而如果你的 URL 压根没匹配上任何路径才会出现 404。路径匹配的优先级遵循“精确匹配优先于通配符”的原则。比如 /user/{id} 和 /user/profile 同时存在访问 /user/profile 时profile 的映射更具体会被选中。如果两个映射的路径完全一样、请求条件也完全一样Spring 启动时就会直接报异常提示 Ambiguous mapping根本不会等到请求进来才出错。日常开发里最常见的 404 原因无非是 Controller 没有被扫描到、类上忘了加 Controller、路径写错、访问的 URL 带了项目前缀而映射没带。2.2 返回的 handler 其实是一串拦截器链条getHandler 返回的类型不是 Method而是 HandlerExecutionChain。这个链条由两部分组成一个是真正要执行的 Handler也就是 Controller 里那个被映射的方法另一个是当前请求匹配到的一组拦截器 HandlerInterceptor。拦截器是根据请求路径和 Handler 类型进行匹配的比如你配置了拦截器只拦截 /api/**那么访问 /api/user 时这个拦截器就会出现在链条里。拦截器列表的存在意味着流程在这里会分出一个分支。一个匹配到的请求满足拦截条件的话会先经过拦截器 preHandle 才能到达 Controller后面执行完还会走 postHandle 和 afterCompletion。很多权限控制的拦截器如果 preHandle 返回了 false流程会直接中止不会继续调用 Controller这也解释了为什么有时你的接口被“拦”掉了却看不到 Controller 里的日志。另一个容易被忽略的细节是如果 getHandler 返回 null也就是所有 HandlerMapping 都没有找到能处理当前请求的映射doDispatch 会执行 noHandlerFound。默认情况下 SpringMVC 会直接返回 404如果你的 DispatcherServlet 设置了 throwExceptionIfNoHandlerFound 为 true这里就会抛出 NoHandlerFoundException进入后面的异常解析环节。这两种处理方式对客户端来说都是 404但在服务端日志和响应的内容上完全不一样。3. HandlerAdapter 的适配过程你的 Controller 代码在哪个环节被真正执行3.1 为什么要引入适配器这一层拿到 HandlerExecutionChain 之后doDispatch 调用 getHandlerAdapter(mappedHandler.getHandler())它遍历 handlerAdapters 集合找到 supports 方法返回 true 的那个适配器。这一步是很多讲流程的人一句话带过但实际很重要的设计。原因在于SpringMVC 的 Handler 并不只有一种形态。在注解驱动成为主流之前Handler 可以是实现了 Controller 接口的类方法签名是 handleRequest(HttpServletRequest, HttpServletResponse)返回 ModelAndView也可以是 HttpRequestHandler直接操作 request 和 response现在最常见的是 RequestMapping 注解方法返回各种类型的对象。这三种 Handler 的调用方式完全不同如果不加一层适配器doDispatch 就必须写一堆 if-else 去判断 Handler 类型。适配器模式把“调用 Handler 的动作”抽象成统一的 handle 方法让 DispatcherServlet 只需要面向 HandlerAdapter 编程。RequestMappingHandlerAdapter 就是为我们最常见的注解方法服务的适配器。它内部会把 Handler 包装成一个 HandlerMethod通过反射调用真实的 Controller 方法。这一步的复杂之处在于参数解析和返回值处理所以引出了下面这两位关键的“门神”。3.2 参数解析器与返回值处理器数据进进出出的两扇门RequestMappingHandlerAdapter 调用 HandlerMethod 时并不会直接 method.invoke(obj, args)而是先对每个参数调用 HandlerMethodArgumentResolver 来解析参数值。SpringMVC 内置了二十多个参数解析器常见的有RequestParamMethodArgumentResolver处理 RequestParam 以及简单类型的参数PathVariableMethodArgumentResolver处理 PathVariable 路径变量RequestResponseBodyMethodProcessor处理 RequestBody同时它也是返回值处理器ModelAttributeMethodProcessor处理 ModelAttribute 和普通对象参数绑定ServletRequestMethodArgumentResolver直接注入 HttpServletRequest、HttpServletResponse每个解析器都有自己支持的参数类型SpringMVC 按顺序遍历第一个 supports 返回 true 的解析器会负责解析这个参数。这个机制解释了大量开发中遇到的“诡异问题”。比如表单提交一个对象结果对象里的字段全部是 null往往就是参数名和 JavaBean 属性对不上或者没有开启 javac 的 -parameters 参数导致解析器拿不到真实参数名再比如传入 LocalDateTime 类型的参数却直接报 400通常是没有注册对应的类型转换器或格式化器。方法调用结束后返回值处理器 HandlerMethodReturnValueHandler 负责决定这个返回值该怎么处理。方法返回一个 String处理器可能把它当成逻辑视图名返回一个对象并且方法上有 ResponseBody处理器就会调用 HttpMessageConverter 把对象序列化成 JSON 写回 response。所以“忘了加 ResponseBody 结果返回了一串视图名字符串”这种问题本质上是返回值处理器选择错了路径。3.3 拦截器链在适配器执行前后的排队顺序适配器真正调用 Controller 方法之前dispatch 会先执行 mappedHandler.applyPreHandle。所有拦截器的 preHandle 按注册顺序正序执行一旦某个 preHandle 返回 false流程立即终止终止后已执行过 preHandle 的拦截器的 afterCompletion 会被触发但还没有执行 preHandle 的拦截器之后不会再执行。Controller 方法正常返回后执行 applyPostHandle这里的顺序是逆序也就是后注册的拦截器先执行 postHandle。这个“正序进入、逆序退出”的设计和 AOP 的调用链是同一个思想类似洋葱模型最先包裹的切面最后退出。最后整个请求结束时会触发 applyAfterCompletion无论请求是正常完成还是抛出异常它都会执行相当于 finally 块。很多开发者在拦截器里做操作日志afterCompletion 记录耗时和权限校验preHandle 放行或拦截时必须理解这个顺序否则日志时间顺序会不对。4. 视图渲染和 JSON 输出请求结束前的最后分岔口4.1 视图解析器如何把一个字符串变成真正的页面如果 Controller 方法没有 ResponseBody返回值会被当作逻辑视图名处理。doDispatch 在拿到 ModelAndView 之后会调用 processDispatchResult其中通过 resolveViewName 遍历 ViewResolver 集合找到能解析出 View 对象的解析器。最常见的 InternalResourceViewResolver 通常配置了前缀和后缀比如bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean方法返回 user/list解析器会把它拼成 /WEB-INF/views/user/list.jsp返回一个 InternalResourceView。View 对象负责真正的渲染render 方法把 Model 中的属性展开为 request 属性然后通过服务端转发 forward 到最终的 JSP 页面。这也是 Model 数据能直接在 JSP 里用 request.getAttribute 取到的原因。这里有个细节为什么视图文件放在 WEB-INF 下面因为 WEB-INF 目录对客户端是不可见的直接输入 URL 无法访问所有页面必须经过 Controller 才能渲染既能防止 JSP 源码被直接访问也能保证页面渲染之前一定有某些业务逻辑被执行过。如果视图路径少了前缀或后缀或者 JSP 文件不存在就会看到 500 错误而不是 404因为视图解析器已经找到了目标但 forward 时找不到资源。4.2 ResponseBody 为什么不用走视图解析加了 ResponseBody 的方法返回值处理器直接选择 RequestResponseBodyMethodProcessor它调用 writeWithMessageConverters从 HttpMessageConverter 列表里挑出一个能处理当前返回值类型和 Content-Type 的转换器把对象序列化后写入 response。Jackson 的 MappingJackson2HttpMessageConverter 就是负责把对象转 JSON 的那个转换器。这个环节解释了为什么有些 JSON 输出问题跟 Controller 方法本身没关系。对象里有循环引用序列化时直接 StackOverflowError对象字段没有 getterJackson 序列化出来是空的LocalDateTime 没有配置 JavaTimeModule序列化直接报错。解决这些问题时你要意识到它们发生在视图/响应输出阶段而不是 Controller 调用阶段排查方向自然不同。用 RestController 其实就相当于在类上加了 Controller 和 ResponseBody让类下所有方法的返回值都走消息转换器。4.3 静态资源为什么会跑到 DispatcherServlet 手里DispatcherServlet 的 url-pattern 配置成 / 时它拦截除了 JSP 之外的所有请求包括 CSS、JS、图片。这些静态资源路径在 HandlerMapping 里多半找不到对应的 Controller 方法如果不加任何处理就会 404。SpringMVC 提供了两个分支来解决mvc:resources 配置 ResourceHttpRequestHandler让它从指定目录查找静态文件mvc:default-servlet-handler 把处理不了的回退给 Servlet 容器自带的默认 Servlet。从流程的角度看静态资源请求进入 DispatcherServlet 后正常 HandlerMapping 匹配不到于是走到 HandlerMapping 集合中那个专门处理静态资源的 ResourceHandlerMapping如果配置了的话由它命中资源处理器。这个分支不在标准“Controller 流程”的叙事里但实际项目里静态资源 404 成了最常见的问题之一值得记住它在流程中的位置。5. 出异常时流程并没有停HandlerExceptionResolver 的分工与介入时机5.1 内置异常解析器三件套分别处理什么doDispatch 的核心方法调用被一个 try-catch 包住如果 HandlerAdapter 执行过程中抛出了异常会被捕获到 dispatchException 变量中之后流程不会立刻返回而是进入 processDispatchResult再转 processHandlerException。processHandlerException 会遍历 handlerExceptionResolvers 集合找出能处理当前异常的解析器。SpringMVC 默认注册了三个异常解析器分工非常明确异常解析器负责的场景典型痛点ExceptionHandlerExceptionResolver处理 ExceptionHandler 注解标注的方法自定义业务异常统一返回结构ResponseStatusExceptionResolver处理 ResponseStatus 注解的异常类或异常方法让异常自动映射到指定 HTTP 状态码DefaultHandlerExceptionResolver处理 SpringMVC 自身抛出的标准异常请求方法不支持时转为 405参数缺失时转为 400这三个解析器按顺序尝试前一个能处理的话后面就不再介入。所以如果你自定义了 ControllerAdvice 里捕获异常的处理逻辑流程通常会停在第一个解析器自定义 HandlerExceptionResolver 的场景反而比较少。5.2 ExceptionHandler 是怎么被找到的ControllerAdvice 本质上是一个特殊的 Spring BeanSpringMVC 在初始化 ExceptionHandlerExceptionResolver 时会收集容器中所有 ControllerAdvice 注解的 Bean以及普通 Controller 内部标注了 ExceptionHandler 的方法构造成一个异常处理方法的映射表。当异常发生时解析器会根据异常类型在映射表中查找最匹配的处理方法匹配的优先级是按继承关系寻找最接近的异常类型。比如你同时定义了 ExceptionHandler(BusinessException.class) 和 ExceptionHandler(Exception.class)业务方法抛出 BusinessException那优先执行前者如果只定义了 Exception 级别的兜底方法那所有异常都会走到它那里。这里容易踩的坑是ExceptionHandler 方法所在类必须被 Spring 扫描到否则控制器异常根本不会进到你预想的处理逻辑。RestControllerAdvice 其实就是在 ControllerAdvice 的基础上给默认方法加了 ResponseBody 语义返回的对象直接作为 JSON 响应体输出。5.3 异常情况下拦截器回调谁能收到拦截器回调与异常的关系也经常被理解错。如果 Controller 方法里抛了异常postHandle 不会执行afterCompletion 会执行并且能拿到异常对象。因为 postHandle 的设计意图是“Controller 已经正常返回后”的额外处理它无法感知异常而 afterCompletion 是请求结束的收尾点无论成功失败都会触发。还有一个细节如果第一个拦截器的 preHandle 返回 false那么第二个拦截器的 preHandle 不会执行第一个拦截器自己的 afterCompletion 会执行第二个拦截器的 afterCompletion 不会执行。这是因为 HandlerExecutionChain 内部记录了已经执行过 preHandle 的拦截器列表afterCompletion 只回调这个列表中的拦截器。这个顺序在做统一日志时非常重要否则你会在异常场景下看到拦截器日志缺失或者顺序混乱误以为是高并发丢日志实际是理解错了流程结构。6. 把流程图当排查工具常见故障现象与定位断点6.1 故障现象对流程环节的速查表在实际项目中我最常用到 SpringMVC 流程知识的场景不是面试而是给线上问题划范围。下面这张表是我自己排查 Web 层问题时常用的对照关系遇到现象先在里面找对应环节再决定断点打在哪故障现象问题大概率落在哪个环节排查切入点所有 Controller 路径都 404HandlerMapping 匹配前Controller 没扫描到、路径前缀不对DispatcherServlet.doDispatch 中看 getHandler 返回值只有某个路径 404HandlerMapping 匹配阶段路径映射写错或条件不匹配查看 RequestMapping 路径和请求 URL启动报 Ambiguous mappingHandlerMapping 注册阶段两个映射条件完全一致检查 Controller 方法上的注解路径请求返回 401/403 或空白页HandlerExecutionChain 拦截器 preHandle 返回 false检查拦截器实现和日志400 Bad Request参数解析或类型转换失败、RequestBody 反序列化失败适配器 invoke 前查看参数解析结果405 Method Not AllowedHandlerMapping 匹配到了路径但请求方法不允许检查 GetMapping/PostMapping 是否写对500 No adapter for handlerHandlerAdapter 环节找不到合适适配器getHandlerAdapter 返回 null返回 JSON 却变成视图名/白标页返回值处理器选错忘了 ResponseBody检查方法或类上注解静态资源 404静态资源处理分支未配置检查 resources 或 default-servlet 配置JSON 循环引用、字段为空HttpMessageConverter 序列化阶段检查 Jackson 配置和对象结构6.2 一次 404 的逐步排查复盘我曾经接手过一个模拟项目的排查前端请求 GET /api/user/123服务器返回 404。表面看是路径问题但实际排查链路可以完整走一遍流程。第一步在 doDispatch 第一行打断点执行到 getHandler 时看返回值。如果 mappedHandler 是 null说明 HandlerMapping 根本没匹配上。接下来检查三个方向Controller 类是否被组件扫描覆盖到类上和方法上的路径拼接后是否和 /api/user/123 一致以及是否存在路径前缀比如配置了 server.servlet.context-path但前端忘了加。我这个例子里 Controller 方法写的是 RequestMapping(/user/{id})类上没有 RequestMapping但前端请求带了多余前缀所以 getHandler 返回 null这个问题属于典型路径映射阶段。如果 getHandler 返回了非空继续看拦截器。某些自定义拦截器 preHandle 里直接 response.getWriter().write(no auth) 并返回 false客户端看到的内容取决于拦截器写了什么如果啥都没写直接返回 false页面可能就是一片空白或者被容器当成 404 处理。这时候 Controller 没执行日志里也没有业务方法单看前端请求会非常困惑。过了拦截器之后如果 Controller 方法内部抛了异常异常解析器会接管。假如你配置了 RestControllerAdvice 统一处理异常但返回的是 404 而不是 200那很可能异常处理方法根本没命中异常被 DefaultHandlerExceptionResolver 转换成了状态码。最终我那个模拟项目的问题就出在拦截器直接返回 false 且没有写任何响应体浏览器看到的就是 404 白页服务端日志什么都没有排查时如果不按流程走很容易在 Controller 和路径之间反复横跳。6.3 调试 SpringMVC 流程时的断点习惯按照流程排查问题时我自己的习惯是在几个固定断点观察状态而不是把整个流程从头到尾单步跟一遍那样太慢且容易晕。第一梯队断点是 DispatcherServlet.doDispatch() 入口看 mappedHandler 和后续 mv、dispatchException 的状态第二梯队是 RequestMappingHandlerMapping.lookupHandlerMethod()看请求路径匹配到了哪些候选映射第三梯队是 InvocableMethod.invokeForRequest()看参数解析完成后真正传入方法的值如果涉及 JSON 输出还会在 RequestResponseBodyMethodProcessor.writeWithMessageConverters() 断点看消息转换器选择和序列化的类型。这几个断点分别对应流程中“映射-适配-视图/响应”三大核心阶段出了任何问题基本都能较快定位。比如参数绑定出来是 null不用猜在 invokeForRequest 里能看到每个参数的解析结果比如 JSON 序列化报循环引用writeWithMessageConverters 会直接抛出异常一眼就知道不在 Controller 而在输出阶段。我自己在带新同学或者排查线上问题的时候很少把流程从头完整背一遍而是先想“现象落在哪个环节”404 大概率是 HandlerMapping 没找到 handler400 大概率是参数解析没通过返回类型不对大概率是返回值处理器选错了路。脑子里的流程一旦和现象对应上排查效率会高很多。如果你正在学 SpringMVC建议抽一个下午在 doDispatch 这几个关键方法上打断点把一次带参数、带拦截器、带异常的真实请求完整走一遍那比背十遍流程图都管用。