1. 从请求入口到分发中心:DispatcherServlet在MVC里的真实位置
1.1 一次请求的完整生命周期
如果你维护过老式的Servlet项目,可能对那种每个URL映射一个Servlet的做法印象深刻:文件上传写一个Servlet,登录校验写一个Servlet,下载导出再写一个Servlet。功能一多,web.xml动辄上百行,维护成本直线上升。Spring MVC的思路完全不同——它只暴露一个前端控制器,也就是标题里这个DispatcherServlet,让所有进入Web应用的请求先汇到这里,再由它根据URL、请求方法、参数类型,把活儿分派给对应的Controller方法。
一次典型的请求是这样走的:浏览器发来GET /user/detail?id=123,请求先被容器交给DispatcherServlet;DispatcherServlet拿到请求后,问HandlerMapping“这个请求谁能处理”;HandlerMapping返回一个HandlerExecutionChain,里面包含匹配的HandlerMethod以及拦截器;接着DispatcherServlet让HandlerAdapter去真正调用这个方法——在这个过程中,MultipartResolver处理文件上传、LocaleResolver决定用哪个语言环境、HandlerExceptionResolver兜底异常;方法执行完返回逻辑视图名后,ViewResolver负责把名字解析成实际页面或JSON输出;在重定向场景下,FlashMapManager还会帮你把参数带到下一个请求。
所以你看,Servlet本身只是薄薄一层壳,真正的处理能力全部来自那些被它初始化、持有并调用的组件。这也正是标题里“一个Servlet撑起整个MVC框架”的含义:DispatcherServlet不负责具体业务,而是负责把所有处理和周边能力拧成一股绳。
1.2 为什么只有它一个Servlet也能撑起全家桶
这里有一个容易误解的地方:在Spring Boot时代,很多人以为自己并没有配置DispatcherServlet,直到看到spring-boot-starter-web里自动注册了一个DispatcherServlet,映射路径是/,才恍然明白整个MVC的入口一直是它。不管你是写@RestController、@Controller、@RequestMapping,还是配置了Filter、Interceptor,最终请求都会落到这个统一的servlet入口上。
它能够撑起全家桶,关键原因在于它设计成了一套“策略模式的聚合器”:它自己不实现具体策略,只负责查找和调用。默认情况下,Spring会从IoC容器里挑出那些类型匹配的Bean,放进对应的组件集合里;如果你没有显式提供某些组件,它还会使用getDefaultStrategies从spring-webmvc的配置文件中加载默认实现。正因为这种“有就用自定义,没有就用默认”的双轨加载逻辑,你才能只写一个Controller、加一个@EnableWebMvc注解,整个框架就能跑得有模有样。
我们日常排查MVC问题,追到最底层时,几乎所有路径都会汇聚到DispatcherServlet的几个核心方法上。理解了组件初始化的机制,等于掌握了排查一切诡异请求分发问题的总钥匙。
2. initStrategies():九大组件初始化的启动顺序与触发时机
2.1 初始化在哪里发生:Servlet的init与FrameworkServlet的onRefresh
处理请求之前,Servlet容器必然先调用Servlet的init()方法,这是Java EE的规范。Spring这里做了一层继承:DispatcherServlet继承自FrameworkServlet,FrameworkServlet又继承自HttpServletBean。HttpServletBean的init()负责把Servlet配置参数绑定到当前实例上,然后调用initServletBean();FrameworkServlet在这里完成WebApplicationContext的初始化,并在最后触发onRefresh();而DispatcherServlet重写的onRefresh(),正是标题里九大组件初始化的真正入口。
// FrameworkServlet中的关键调用链 @Override protected final void initServletBean() throws ServletException { // ... 创建WebApplicationContext this.webApplicationContext = initWebApplicationContext(); // 容器刷新完成后回调 onRefresh(this.webApplicationContext); } // DispatcherServlet中重写的方法 @Override protected void onRefresh(ApplicationContext context) { initStrategies(context); } // 九大组件初始化方法一字排开 protected void initStrategies(ApplicationContext context) { initMultipartResolver(context); initLocaleResolver(context); initThemeResolver(context); initHandlerMappings(context); initHandlerAdapters(context); initHandlerExceptionResolvers(context); initRequestToViewNameTranslator(context); initViewResolvers(context); initFlashMapManager(context); }从这段源码可以直观看到,九大组件的加载顺序是固定的:先解析器类组件,再路由与执行组件,然后是异常处理和视图组件。这些方法在执行时只干一件事——从容器里捞指定类型的Bean,捞不到就回退到默认配置。
注意:在Spring Boot中,这一步依然存在,只是被
ServletRegistrationBean包装后由内嵌容器触发了。你可以在日志里看到Initializing Spring DispatcherServlet和Completed initialization in X ms,这两行日志分别对应初始化开始和最后完成。
2.2 九大组件的初始化先后顺序为什么不能乱
为什么MultipartResolver排第一个?因为文件上传解析是偏底层的输入解析能力,后面的HandlerMapping、HandlerAdapter在匹配处理器、绑定参数时,需要拿到已经解析好的MultipartFile对象。如果顺序颠倒,后加载的组件就可能在请求处理中取不到完整参数。
HandlerMapping和HandlerAdapter的顺序也讲究:前者负责“找得到谁”,后者负责“怎么调用”。标题里九大组件的顺序基本遵循了请求处理的自然流向:先解析输入,再匹配路由,然后适配调用,接着处理异常,最后解析视图和传递重定向参数。
这里出现过一类比较坑的情况:有人自定义了一个HandlerInterceptor,希望它在所有请求前打印日志,结果发现部分请求根本没有经过它。排查到最后,是因为这个拦截器被注册到了某个自定义HandlerMapping上,而默认的RequestMappingHandlerMapping不包含它。组件的加载顺序决定了多个HandlerMapping之间的查找顺序,也决定了拦截器挂在哪个路由链条上,这类问题没有对初始化机制的理解根本无从下手。
2.3 默认与缺省的取舍:哪些组件可以不提供
再强调一次双轨加载机制,这是DispatcherServlet设计的核心。以initViewResolvers为例:
private void initViewResolvers(ApplicationContext context) { this.viewResolvers = null; if (this.detectAllViewResolvers) { Map<String, ViewResolver> matchingBeans = BeanFactoryUtils .beansOfTypeIncludingAncestors(context, ViewResolver.class, true, false); if (!matchingBeans.isEmpty()) { this.viewResolvers = new ArrayList<>(matchingBeans.values()); AnnotationAwareOrderComparator.sort(this.viewResolvers); } } // 容器中没有任何ViewResolver时,加载默认策略 if (this.viewResolvers == null) { this.viewResolvers = getDefaultStrategies(context, ViewResolver.class); } }注意detectAllViewResolvers这个开关,默认是true。它会从容器和所有父容器里找出全部同类型Bean;如果设为false,则只会找名字叫viewResolver的那个。绝大多数项目并不需要把加载逻辑调成false,但如果你在多个子容器里各配置了一个ViewResolver,并且希望精确控制谁生效,这个开关就有用了。
在九个组件里,MultipartResolver、LocaleResolver、ThemeResolver、RequestToViewNameTranslator、FlashMapManager都有默认实现兜底。真正影响巨大的两个组件是HandlerMapping和HandlerAdapter:容器里没有它们的Bean时,Spring会加载默认的BeanNameUrlHandlerMapping、RequestMappingHandlerMapping和对应的Adapter;可一旦我们自己向容器注册了一个HandlerMapping,默认那套就不会再出现,除非额外把它也注册进去。这一点是“为什么我加了一个自定义Controller后所有接口都404”的常见根源。
3. 逐组件拆解:从MultipartResolver到FlashMapManager各自管什么
3.1 MultipartResolver:文件上传的守门员
MultipartResolver负责判断当前请求是不是multipart/form-data,如果是,就把请求包装成MultipartHttpServletRequest,以便后续用@RequestParam("file") MultipartFile file直接拿到文件。Spring Boot默认注册了StandardServletMultipartResolver,它是基于Servlet 3.0的PartAPI做的;老式SSM项目常配CommonsMultipartResolver,内部通过Apache Commons FileUpload解析。
这个组件的配置细节直接影响文件大小上限和临时目录。使用StandardServletMultipartResolver时,限制写在MultipartConfigElement里:
@Bean public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory = new MultipartConfigFactory(); factory.setMaxFileSize(DataSize.ofMegabytes(10)); factory.setMaxRequestSize(DataSize.ofMegabytes(20)); return factory.createMultipartConfig(); }我踩过一个坑:文件明明只有几MB,配置也写对了,但上传时一直报MaxUploadSizeExceededException。排查后发现,那个项目同时存在两个MultipartResolverBean,而initMultipartResolver的逻辑是如果找到一个就算成功,并不做顺序排序,结果某个配置了较小阈值的Resolver抢占了位置。从那以后,我看到项目里出现第二个MultipartResolver就会特别警惕。
3.2 LocaleResolver与ThemeResolver:本地化与皮肤
LocaleResolver负责解析请求对应的Locale(语言环境),Spring MVC用它来决定消息源MessageSource取哪套文案,以及时间、数字格式化成什么样。默认实现是AcceptHeaderLocaleResolver,直接读HTTP头里的Accept-Language;然而很多后台管理系统并不可靠,于是大家会改成SessionLocaleResolver或CookieLocaleResolver,让用户自己从页面上切换语言。
CookieLocaleResolver是我用得最多的实现,因为它把语言选择存在浏览器Cookie里,关闭浏览器再打开依然有效,也不需要服务端存储会话状态。配置起来也很简单:
@Bean public LocaleResolver localeResolver() { CookieLocaleResolver resolver = new CookieLocaleResolver(); resolver.setCookieName("LOCALE"); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); return resolver; }ThemeResolver和它思路类似,解析当前主题源,然后ThemeSource提供CSS、图片等资源定位。说句实话,在现代前后端分离架构下,主题切换基本由前端框架接管,后端ThemeResolver用得越来越少,但它依然是九大组件之一,你至少得知道它在整个请求链里处于什么位置,否则看别人的老项目配置时容易一头雾水。
3.3 HandlerMapping与HandlerAdapter:路由与反射执行
HandlerMapping维护“请求条件→处理器”的映射。RequestMappingHandlerMapping会扫描所有标注了@RequestMapping的方法,把URL、HTTP方法、参数、Header等条件组织成RequestMappingInfo;请求进来时,它按精确匹配、路径模式匹配、参数匹配的优先级命中目标。多个HandlerMapping可以按照Order排序,先询问前面的能否处理,不能处理再往后走。
HandlerAdapter则解决“调用什么样的处理器”的问题。RequestMappingHandlerAdapter专门负责调用带有@RequestMapping的方法:把请求里的路径变量、查询参数、请求体按注解绑定到方法参数上,调用方法,然后将返回值适配成ModelAndView或直接写到Response里。
// 一个Controller方法要被正确调用,依赖HandlerAdapter完成参数绑定 @GetMapping("/user/{id}") public UserInfo getUser(@PathVariable Long id, @RequestParam(required = false) String detail) { // ... }处理@PathVariable、@RequestParam这种参数绑定的核心是HandlerMethodArgumentResolver列表,它虽然不属于九大组件,但由RequestMappingHandlerAdapter内部持有。当你自定义了某个注解希望直接注入到Controller参数里,实际就是在扩展一个HandlerAdapter内部的参数解析器列表。
3.4 HandlerExceptionResolver:异常的统一收容所
Controller方法抛出的异常默认不会直接抛给Servlet容器,而是先经过HandlerExceptionResolver链。ExceptionHandlerExceptionResolver负责处理@ExceptionHandler标注的方法;DefaultHandlerExceptionResolver处理Spring MVC内置异常,比如NoHandlerFoundException返回404、HttpRequestMethodNotSupportedException返回405。
@ExceptionHandler能生效,底层就是这个Resolver在起作用。它的查找规则是:先找当前Controller里@ExceptionHandler标注的方法,找不到再往@ControllerAdvice里找。我在项目里见过一个很有意思的问题:子类是Controller,父类写了个@ExceptionHandler,子类自己又写了一个同名异常处理方法,结果却不生效。最后定位到规则细节——Spring会先从当前类找,找不到才去父类,但如果用@ControllerAdvice,则遵循另一个优先级逻辑。排查这种问题,直接看ExceptionHandlerExceptionResolver的getExceptionHandlerMethod方法最合适。
3.5 RequestToViewNameTranslator与ViewResolver:视图名的最后一公里
RequestToViewNameTranslator是一个容易被忽略的组件。当Controller方法返回void或者没有显式指定视图名时,它会从请求URL推导出一个默认视图名。比如访问/user/detail且方法没返回视图名,默认翻译结果就是user/detail,再交给ViewResolver解析。大多数Restful接口用不上它,但老式服务端渲染页面项目经常依赖这种默认行为。
ViewResolver则是把逻辑视图名变成View对象的关键角色。常见的InternalResourceViewResolver会拼前缀后缀,把user/detail翻译成/WEB-INF/jsp/user/detail.jsp。Spring Boot整合模板引擎后,ThymeleafAutoConfiguration会注册ThymeleafViewResolver,模板路径拼装逻辑类似。这里最容易出的错是逻辑视图名写错层级、前缀后缀配置不对,导致Could not resolve view with name 'xxx',其实要查的就是ViewResolver列表里谁有资格解析这个视图名。
3.6 FlashMapManager:重定向场景下的跨请求缓存
重定向(Redirect)是浏览器发起的新HTTP请求,之前request里的参数不会带过去。常规做法是在URL后面拼参数,但有时参数太复杂或不想暴露在地址栏,这时就需要FlashMap。FlashMapManager在重定向前保存参数,在重定向请求到达时读取一次后立即清除,实现“只存活一次请求”的临时数据传递。
Spring MVC中的RedirectAttributes接口就是配合FlashMapManager使用的:
@PostMapping("/save") public String save(RedirectAttributes ra) { // 保存到FlashMap,重定向后还能取到 ra.addFlashAttribute("msg", "保存成功"); return "redirect:/list"; } @GetMapping("/list") public String list(Model model) { // model中已经自动包含上一次FlashMap带过来的msg return "list"; }默认的SessionFlashMapManager把FlashMap存在HttpSession里,重定向后从Session提取再删除。如果Session不可用或者接口返回纯JSON,这个组件基本没有存在感;但一旦你手写重定向跳转页面,FlashMapManager就是那个“看不见的参数搬运工”。
4. 启动排障:从空白页到No mapping found的诊断思路
4.1 现象一:所有请求都404,HandlerMapping常量为空
有次接手一个老项目,启动日志没有任何异常,但每个接口都404。疯狂检查WebMvc配置和注解扫描包后,发现Controller确实被扫描到了,IoC容器里也有Bean,可HandlerMapping里就是找不到这些路径。
后来看了启动过程中加载的HandlerMapping,发现容器里出现了一个自定义的HandlerMappingBean,它覆盖了默认的RequestMappingHandlerMapping。因为initHandlerMappings的逻辑是把容器里所有HandlerMapping都收集起来,如果自定义的HandlerMapping自己没有任何路由映射,同时默认那个又没有被注册进去,结果就是空转。这个问题的本质是:Spring MVC并没有硬性保证默认HandlerMapping一定会被保留,它只保证“有的话全收,没有的话才用默认”。
解决办法很直接:要么去掉那个空转的自定义HandlerMapping,要么手动把RequestMappingHandlerMapping注册进去,并且给不同的HandlerMapping排好Order。诊断时最有效的动作是先看运行时容器里究竟有哪几个HandlerMapping实例,再对着DispatcherServlet的handlerMappings字段看一眼,就能定位是哪一环断了。
4.2 现象二:文件上传接口注入MultipartFile为空
另一个高频问题:接口上明明写了@RequestParam("file") MultipartFile file,结果运行时参数是null,再仔细看,MultipartResolver根本没有执行解析。最常见的原因是项目中用的是老式web.xml部署,没有配置multipart-config或者CommonsMultipartResolver缺失。
Servlet 3.0之后,容器解析multipart请求的前提是Servlet注册时设置了MultipartConfig。如果DispatcherServlet在web.xml里没有加<multipart-config>,StandardServletMultipartResolver拿到请求时调用request.getParts()会抛出异常或直接跳过解析。Spring Boot内嵌容器默认给DispatcherServlet配置了MultipartConfig,所以Boot项目很少遇到这个问题。
排查路径建议:先确认DispatcherServlet实例里multipartResolver字段是否为空;如果不是空,再看请求类型是否是MultipartHttpServletRequest;如果是,再检查参数名是否匹配、是否真的用对了@RequestParam。逐层往下走,通常比盲目改配置更快。
4.3 现象三:视图解析失败,报错却没有剩余堆栈
Could not resolve view with name 'xxx'这类日志往往只是完整异常的一部分。如果不看上下文,很容易以为是ViewResolver配置错了,但实际原因可能在Controller根本没有执行到返回视图那一步就抛了异常,异常又被某个HandlerExceptionResolver吞掉后转成了别的结果。
有一次我把一个@RestController改成了@Controller,返回对象本来需要@ResponseBody才不会被当作逻辑视图名。启动后访问接口,日志打出“Could not resolve view”,第一反应是InternalResourceViewResolver前缀后缀写错了。后来一查,真正原因是Controller方法返回了一个自定义DTO,Spring把它当逻辑视图名,然后找不到对应视图。这类误判的根源在于对组件职责边界不够清晰:ViewResolver只负责把字符串变成View,它不会去猜返回值本来该不该序列化成JSON。
排障时,我习惯把日志级别调到DEBUG,重点看DispatcherServlet里processDispatchResult方法前面的处理链日志。从请求进来到ExceptionResolver介入,每一步都有迹可循,比盯着最后一行报错要有效得多。
4.4 排查组件初始化状态的两个实用手段
第一,在DispatcherServlet初始化完成后,通过实现ApplicationListener<ContextRefreshedEvent>或者在WebMvcConfigurer配置类里,临时打印DispatcherServlet持有的组件实例列表。这个方法能直观看到九个组件分别是什么实现类。
@EventListener public void onStartup(ApplicationReadyEvent event) { DispatcherServlet ds = event.getApplicationContext() .getBean(DispatcherServlet.class); System.out.println(ds.getHandlerMappings()); System.out.println(ds.getViewResolvers()); }第二,利用WebMvcConfigurer的接口方法来观察容器中的组件状态。如果你重新注册一个组件,原有的默认实现会不会被覆盖,可以直接从RequestMappingHandlerAdapter的argumentResolvers列表里看到。很多时候,项目里的“奇怪现象”其实不是框架bug,而是某个看起来不起眼的组件被替换成了你并不认识的实现类。
5. 定制组件之前必须想清楚的事:替换、排序与作用域
5.1 通过WebMvcConfigurer扩展而不是重写DispatcherServlet
很多人想“既然DispatcherServlet里组件都是从容器拿,那我直接往容器里丢一个Bean不就行了?”理论可行,但容易踩覆盖顺序的坑。Spring官方推荐的姿势是实现WebMvcConfigurer接口,重写里面的addInterceptors、configureMessageConverters、addViewControllers等方法。这些回调最终会由WebMvcConfigurationSupport里的默认组件构建器调用,保证你能在既有的容器组件集合上做“加法”,而不是替换成全新的一套。
举个例子,你想给某个Controller方法加自定义参数绑定,最稳妥的做法是:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new UserInfoArgumentResolver()); } }如果直接@Bean public RequestMappingHandlerAdapter requestMappingHandlerAdapter()重新定义,就等于把Spring MVC内部很多默认的配置全部绕开了——包括默认的HandlerMethodArgumentResolver、ReturnValueHandler、消息转换器列表。短时间看似正常,等到处理复杂请求参数时各种缺少解析器的问题会接踵而至。
5.2 多HandlerMapping排序与@Order的实际表现
当应用里同时存在RequestMappingHandlerMapping和自定义的HandlerMapping时,它们的执行顺序按Order值决定,数值小的先执行。常见的扩展场景是给某些特殊URL提供独立的处理器,比如老系统的Action类适配层。这种构架下,建议把基于注解的RequestMappingHandlerMapping留在最后兜底,保证所有常规接口不被自定义规则抢走。
调试的方法也很直接:给DispatcherServlet打个断点,看handlerMappings数组的排列顺序;或者从日志中搜索Mapped to,查看某个请求到底匹配到了哪个HandlerMapping。实际项目中,把自定义HandlerMapping的Order设成1、默认的设成2,基本能满足绝大多数“先走特殊路由、再走常规路由”的需求;反过来设,特殊路由等于不存在。
5.3 我在项目里替换LocaleResolver和配FlashMap的体会
替换组件这件事,最怕的是“改完A影响B”。比如我曾在老系统里把AcceptHeaderLocaleResolver换成了CookieLocaleResolver,本意是让用户语言选择不再受浏览器请求头左右。结果发现一部分接口返回的日期格式突然变了,因为Locale变了导致DateTimeFormat的默认解析方式跟着变。后来我在Resolver里锁定了Locale.CHINA作为兜底值,才让格式稳定下来。
FlashMapManager的定制机会相对少,但也有值得注意的点:默认SessionFlashMapManager要求会话可用,如果接口是无状态、跨域重定向模式,FlashMap数据可能存不进去。我在一个单点登录对接项目里用过RequestDataValueProcessor配合FlashMap做表单参数透传,绕开了Session依赖,效果不错。但这种做法对请求生命周期理解要求高,不建议新手直接用,能走RedirectAttributes的常规路线就尽量走常规路线。
最后再分享一个经验:每次改动九大组件中的任意一个,别只看当前接口通不通,要再用真正生产形态的请求验证一遍——带上Cookie、跨域头、文件上传、错误参数,这些边角路径最能暴露组件替换后的副作用。MVC框架的魅力在于它把所有可变点都设计成可插拔的策略,但插拔得越多,你对整体初始化的理解就越必须跟上。组件之间环环相扣,牵一发而动全身,这既是风险的来源,也是DispatcherServlet作为一个Servlet能撑起整套MVC框架的根本底气。