资讯动态

SpringMVC DispatcherServlet与请求映射全解析:从原理到实战避坑

发布时间:2026/10/21 18:01:54 来源:尧图企业网站定制
Day42 这篇我们来看 SpringMVC 里最绕不开的两个东西DispatcherServlet 和请求映射。如果你已经啃过一段 Spring 的 Ioc、Bean 生命周期、AOP接下来想真正上手做 Web 接口那十有八九要跟 SpringMVC 打交道。网上讲这块的文档不少但大多是“配置照抄、能跑就算成功”至于 DispatcherServlet 为什么长这样、请求是怎么一步一步落到某个 Controller 方法上的很少有人说透。这篇文章不打算从头抄一遍官方文档而是把 DispatcherServlet 的职责、初始化链路、请求映射的匹配规则以及我在实际项目里踩过的几个和映射相关的坑完整捋一遍。适合刚学完 Spring 基础、正准备接触 Web 层的同学也适合被 404、400 反复折磨、想搞明白根源的朋友。看完你至少能做到配置出问题时不靠乱猜而是顺着 DispatcherServlet 的处理链路去定位。1. DispatcherServlet 到底在 SpringMVC 里扮演什么角色1.1 前端控制器模式所有请求都要过的一道总闸先聊一个很多初学者没想明白的问题为什么用了 SpringMVC 之后项目里几乎看不到传统的 Servlet 了明明以前写 Servlet 也能处理 HTTP 请求Controller 也就是一个普通方法是谁在替我们做分发答案是 DispatcherServlet。它本身就是一个 Servlet只不过它不在业务代码里出现而是作为整个 Web 应用的“前端控制器”统一接收所有请求。前端控制器模式简单说就是所有请求先进入同一个入口由这个入口做权限、参数、路由、异常处理等公共逻辑再转发给真正干活的后端组件。你可以把它类比成公司前台所有访客都先到前台登记前台再根据来访目的通知对应部门而不是每个部门都单独开一个大门接待访客。在没有 SpringMVC 的纯 Servlet 时代开发人员要自己给每个功能写一个 Servlet然后在 web.xml 里逐个注册再手动保证编码、登录校验、异常处理等逻辑不重复。一旦项目变大Servlet 数量多了公共逻辑散落在各处维护起来非常痛苦。DispatcherServlet 把“接收请求”和“执行业务”彻底拆开Controller 里只管业务逻辑公共的请求解析、参数绑定、视图解析、异常处理全部下沉到框架层。在 SpringMVC 中DispatcherServlet 的继承链是这样的HttpServlet - HttpServletBean - FrameworkServlet - DispatcherServlet从这个继承链可以看出DispatcherServlet 终究还是一个标准 Servlet 容器能识别的 Servlet。它由 Servlet 容器比如 Tomcat负责实例化和初始化然后在容器启动时注册到指定路径上。一旦容器把匹配的请求交给它之后就完全是 Spring 容器内部的事情了。1.2 DispatcherServlet 的初始化过程中到底干了什么很多人配置过load-on-startup也知道 DispatcherServlet 会在 Web 应用启动时初始化但初始化之后它做了什么一直是个黑盒。实际上DispatcherServlet 的初始化核心逻辑在initStrategies()方法里它会在onRefresh()阶段被调用一次。这一串初始化动作的主要目的是把 SpringMVC 运行所依赖的九大组件全部准备齐全。我把我平时调试时关注的那几个组件列出来它们决定了请求进来之后由谁处理、怎么处理、出错怎么办组件作用默认实现没有显式配置时HandlerMapping根据请求找到对应的 Handler 和拦截器链RequestMappingHandlerMappingHandlerAdapter调用 Handler 方法完成参数绑定和返回值处理RequestMappingHandlerAdapterHandlerExceptionResolver统一处理 Handler 抛出异常ExceptionHandlerExceptionResolver 等ViewResolver根据视图名解析出具体 ViewInternalResourceViewResolverMultipartResolver处理文件上传请求StandardServletMultipartResolverLocaleResolver解析国际化区域信息AcceptHeaderLocaleResolver重点说一下 HandlerMapping。在没有额外配置任何 HandlerMapping 的情况下SpringMVC 会默认注册两个一个是BeanNameUrlHandlerMapping它把请求 URL 映射到同名的 Bean 上另一个就是核心的RequestMappingHandlerMapping它负责收集所有带RequestMapping注解的方法并建立 URL 到方法的映射表。我们平时写的GetMapping、PostMapping最终都会被它收集进一个叫MappingRegistry的注册表里。所以“为什么我只写了一个 Controller 方法SpringMVC 就知道请求该交给谁”这个问题的答案并不是魔法而是容器启动时已经把映射关系全部建好了。后续请求到达时DispatcherServlet 只需要在注册表里做查找匹配。理解了这一点很多“为什么 404”“为什么方法没生效”的问题就都有了排查方向。2. 一次 HTTP 请求怎么一步步映射到 Controller 方法2.1 doDispatch 的核心处理流程当请求经过 Servlet 容器来到 DispatcherServlet 后真正干活的方法是doDispatch(HttpServletRequest request, HttpServletResponse response)。网上很多文章只给结论不画调用链导致读者只能死记硬背。这里我用最简单的语言把关键步骤讲清楚。整个处理流程可以拆成这几步请求先经过 Servlet 的service()分发到doGet/doPost最终统一进入doDispatch()。doDispatch()调用getHandler(processedRequest)遍历所有 HandlerMapping找到能匹配当前请求的那个 Handler同时拿到该 Handler 绑定的拦截器链。如果没有找到对应的 Handler框架会直接抛出NoHandlerFoundException最终根据配置返回 404 或自定义错误页。找到 Handler 后再通过getHandlerAdapter(mappedHandler.getHandler())找到能“执行这个 Handler”的 HandlerAdapter。HandlerAdapter 负责调用 Controller 方法并在调用前后完成参数解析、数据转换、返回值处理。如果执行过程中抛出了异常则由 HandlerExceptionResolver 统一处理。最终通过 View 渲染将结果写回 Response。这七个步骤里最容易被忽略的是第 2 步的细节getHandler()返回的不是单纯的 Controller 对象而是一个HandlerExecutionChain它包含 Handler 和一组拦截器。拦截器在 Controller 方法之前、之后、以及整个请求完成之后分别回调。很多人以为只有 AOP 能做环绕增强实际上 SpringMVC 的拦截器也做类似的事情只是作用范围限定在 Web 层。2.2 RequestMappingHandlerMapping 的匹配与排序规则再来详细看映射的查找过程。RequestMappingHandlerMapping在初始化时会扫描所有带有Controller注解的 Bean 和所有标记了RequestMapping的方法把每个方法封装成一个HandlerMethod注册到MappingRegistry。注册信息主要包括请求路径、HTTP 方法、请求参数条件、请求头条件、自定义的consumes/produces限制等。当请求进来时框架会按照“匹配条件最具体者优先”的原则选择 Handler。举个例子如果同时存在这两个映射GetMapping(/demo/{id}) public String demoA(PathVariable Long id) { ... } GetMapping(/demo/123) public String demoB() { ... }请求GET /demo/123时SpringMVC 一定会选择demoB因为它不包含路径变量匹配条件更精确。这个规则是由RequestMappingInfo的compareTo()方法实现的优先比较路径模式的精确程度然后是 HTTP 方法约束、参数约束、请求头约束等。这里有个经常被忽略的点多个匹配条件都满足时框架看重的不是“谁先定义谁先执行”而是“谁的条件更具体”。如果你依赖 Controller 方法的声明顺序来决定路由那就大错特错了。实际项目里遇到“两个映射都能匹配但是走了意外那个”的情况十有八九是路径设计没有做好唯一性约束。2.3 RequestMapping 家族路径、方法、参数、请求头都能参与匹配初学者往往只把RequestMapping当成一个“路径注解”实际上它的匹配维度丰富得多。我实际用过的核心属性大概有这几类路径匹配value/path支持精确路径、Ant 风格通配符*匹配一段路径**匹配多层路径。请求方法method可以指定RequestMethod.GET/POST/PUT/DELETE等。参数条件params比如params activetrue表示请求中必须包含名为active且值为true的参数params !deleted表示不能包含deleted参数。请求头条件headers限制请求头必须满足什么条件。内容类型consumes限制请求的 Content-Type。返回类型produces限制响应的 Content-Type。举个稍微完整的例子RestController public class OrderController { GetMapping( path /order/{orderNo}, params activetrue, headers X-Clientios, produces application/json ) public Result getOrder(PathVariable String orderNo) { // 业务逻辑 } }这段代码表示只有GET方法、路径为/order/{orderNo}、携带activetrue参数、请求头X-Client为ios、并且客户端希望接收 JSON 格式响应时才会进入这个方法。任何一个条件不满足SpringMVC 都会认为当前映射不匹配继续找下一个 Handler找不到最终就返回 404。不过实际项目中我不建议一下子堆这么多条件。params、headers这种条件写多了接口的可读性和自文档性会变差。最常用的还是GetMapping、PostMapping这种组合注解加上必要的路径变量。后面有特殊场景需要限制来源或版本号时再考虑用请求头条件。3. 两种主流配置方式web.xml 和 JavaConfig 要怎么选3.1 基于 web.xml 的经典配置老项目里最常见的配置方式还是在web.xml中直接声明 DispatcherServlet。这里有一个很多人容易写错的地方既有 Spring 的ContextLoaderListener又有 DispatcherServlet它们分别加载不同的 Spring 容器。父容器由ContextLoaderListener创建负责加载数据源、Service、Mapper 等子容器由 DispatcherServlet 创建负责加载 Controller、HandlerMapping、ViewResolver 等 Web 层组件。一个经典的配置片段是这样web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-context.xml/param-value /context-param 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-mapping /web-app这里最关键的是url-pattern//url-pattern和url-pattern/*/url-pattern的区别。前者是“除了 JSP 和静态资源之外的默认路径都交给 DispatcherServlet”这也是 SpringMVC 项目里推荐的写法后者是“所有请求都交给 DispatcherServlet”会把 JSP 也拦截掉导致页面无法正常渲染。我在项目里见过不止一次因为写了/和/*混用导致静态资源或 JSP 无法访问的问题强烈建议新人在初始化配置时第一眼就检查这个标签。3.2 基于 JavaConfig 的无 XML 配置新项目我更推荐用 JavaConfig 方式。在 Servlet 3.0 环境下不需要写web.xml只要写一个类实现WebApplicationInitializer或者直接继承 Spring 提供的AbstractAnnotationConfigDispatcherServletInitializer即可。我日常用的模板是public class WebAppInitializer 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[] { / }; } }代码量比 XML 少而且配置类本身就是普通的 Spring 类可以通过注入、条件判断来做更灵活的初始化。getRootConfigClasses()对应父容器getServletConfigClasses()对应 DispatcherServlet 的子容器这个边界和 web.xml 里的父/子容器概念是一致的。再配合一个 Web 层配置类Configuration EnableWebMvc ComponentScan(basePackages com.example.web) public class WebConfig implements WebMvcConfigurer { Override public void configureViewResolvers(ViewResolverRegistry registry) { registry.jsp(/WEB-INF/views/, .jsp); } }EnableWebMvc是这组配置的核心开关它等效于 XML 配置里的mvc:annotation-driven/开启之后才会注册默认的 HandlerMapping、HandlerAdapter、异常处理器等组件。如果不加这个注解Controller 上很多基于注解的功能比如ResponseBody、参数自动绑定都不会生效。3.3 两种方式怎么选一张表看明白维度web.xml 方式JavaConfig 方式配置位置web.xml 单独文件Java 类与代码在一起可读性集中、但是配置多了之后很长分散到多个配置类结构性更强动态配置弱几乎只能写死强可通过 Java 逻辑控制与 SpringBoot 迁移方向相反较吃力概念一致迁移难度低老项目维护常见需要能看懂主流趋势我的建议是如果是维护老项目两种配置都要会看如果是自己新起项目直接用 JavaConfig。原因很简单现在国内绝大多数新项目都是 SpringBoot 风格SpringBoot 底层的自动配置思路和 JavaConfig 一脉相承。在 SpringMVC 阶段就把 JavaConfig 的父子容器概念搞清楚后面看 SpringBoot 的源码会轻松非常多。4. 手写一个最小可运行的 SpringMVC Demo4.1 依赖和环境准备配置原理讲得再多不如亲手跑通一次。下面这个 Demo 我在多个版本的 Spring 上验证过JDK 1.8/11/17 都能跑Maven 工程打成 War 包部署到外部 Tomcat或者用插件内嵌容器启动都行。pom.xml 加上这几个依赖就够了dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.39/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependenciesspring-webmvc这一个依赖已经传递引入了 spring-web、spring-context、spring-core 等模块因此不需要手动把 Spring 全家桶都列一遍。javax.servlet-api的作用是编译时提供 Servlet 相关接口打 War 包时不需要打包进去因为运行环境里的 Tomcat 已经自带了。如果你用的 Spring 6.x / 7.x注意javax.servlet已经切换成了jakarta.servlet坐标也要跟着换成jakarta.servlet-api这一步很多人升级时容易踩坑。4.2 编写启动类、配置类和 Controller启动类直接用前面提到的AbstractAnnotationConfigDispatcherServletInitializerpublic class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { Override protected Class?[] getRootConfigClasses() { return new Class?[0]; } Override protected Class?[] getServletConfigClasses() { return new Class?[] { WebConfig.class }; } Override protected String[] getServletMappings() { return new String[] { / }; } }如果没有任何 Service、Dao 需要加载根容器配置返回空数组即可。这个设计可能让某些人疑惑为什么可以不配根容器因为父容器的作用是承载业务层 BeanDemo 里没有业务层DispatcherServlet 子容器单独完成 Web 层的装配即可。WebConfig 配置类Configuration EnableWebMvc ComponentScan(basePackages com.example.demo) public class WebConfig implements WebMvcConfigurer { Override public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { configurer.enable(); } }configureDefaultServletHandling()这行建议加上它会把静态资源的请求转发给容器的默认 Servlet 处理。不写的话启动后访问 HTML、JS、CSS 可能会 404。Controller 写一个最简单的接口RestController public class HelloController { GetMapping(/hello/{name}) public MapString, String hello(PathVariable String name) { MapString, String result new HashMap(); result.put(message, Hello, name); return result; } PostMapping(/calc) public int calc(RequestParam int a, RequestParam int b) { return a b; } }这里用了RestController等价于Controller加ResponseBody方法返回值会直接通过 HttpMessageConverter 序列化为 JSON 写回客户端。如果误用了Controller而方法上忘了加ResponseBody结果就会变成在努力找名为hello的视图最终大概率报 404 或抛异常这是初学者最容易踩的坑之一。4.3 打包运行验证请求映射是否真的生效打包命令mvn clean package -DskipTests生成springmvc-demo.war后放到 Tomcat 的 webapps 目录下启动。假设应用上下文名是springmvc-demo访问http://localhost:8080/springmvc-demo/hello/zhangsan正常响应{message:Hello, zhangsan}再测试 POST 接口curl -X POST http://localhost:8080/springmvc-demo/calc?a1b3正常响应4这段测试过程看起来很简单但它实际上验证了三条链路DispatcherServlet 被正确加载、RequestMappingHandlerMapping 成功注册了/hello/{name}和/calc两个映射、HandlerAdapter 完成了PathVariable和RequestParam的参数绑定。如果任何一个环节出错表现出的现象都不会是预期结果排查时你就知道问题出在“映射注册”还是“参数绑定”。5. 请求映射相关的常见坑我的排查链路5.1 404 不等于没写映射先分清是谁返回的 404遇到 404 时我第一件事永远是搞清楚这个 404 是 Tomcat 返回的还是 SpringMVC 返回的。判断方法很简单看响应体内容和日志如果响应体是 Tomcat 默认的红色错误页说明请求根本没进入 DispatcherServlet问题在 Servlet 容器的路径匹配或配置如果响应体是 SpringMVC 的 JSON 错误信息或者日志里有NoHandlerFoundException、No mapping found for HTTP request with URI这类关键字说明请求已经进入 DispatcherServlet只是映射没匹配上。针对“进入 DispatcherServlet 但没匹配上”的情形优先检查三件事Controller 是否真的被 Spring 容器扫描到了。你可以临时在 Controller 构造器里加一行日志如果容器启动时没有打印说明ComponentScan的 basePackages 没有覆盖到它。方法上的注解是否写全了比如只写了RequestMapping但没写 method那它确实能匹配所有方法但如果前端用了不同的 HTTP 方法就要看映射是否发生了冲突。请求路径末尾斜杠问题。SpringMVC 默认开启后缀匹配时/hello和/hello/可能被视作不同路径。如果路径是精确匹配建议前后端对齐公共约定不在接口路径末尾加无意义的斜杠。5.2 包扫描边界Controller 放在子容器外层就会失效我遇到过这样一个问题项目里 Controller 明明扫描到了日志也显示注册成功了几个 HandlerMethod但请求始终 404。后来发现这个 Controller 定义在了ComponentScan扫描范围之外的包按理说应该不生效但它却有别的 Bean 依赖它启动也没白屏所以外部看起来一切正常。问题出在父子容器和扫描边界上。WebConfig 的子容器只扫描 Web 层包RootConfig 的父容器扫描业务层包。如果某个类既不是 Web 层组件又放在子容器扫描范围内或者一个 Controller 被父容器扫到了但 DispatcherServlet 又在子容器里找不到它就会出现“Bean 存在但请求无法映射”的怪现象。最佳实践是做一个非常明确的包划分父容器扫描com.example.service、com.example.dao子容器扫描com.example.controller、com.example.advice。不要偷懒给两个容器配同一个basePackage那样会重复实例化还可能出现代理失效、事务失效等莫名其妙的问题。5.3 静态资源被拦截找不到页面这个问题我在前面已经提过一次但因为出现频率太高单独拎出来再说一遍。当url-pattern明明是/时为什么访问/css/app.css还是会走到 DispatcherServlet因为默认 ServletHandler 没有被开启。所以要么在 JavaConfig 里重写configureDefaultServletHandling并调用configurer.enable()要么使用前文提到的EnableWebMvc加适当配置。如果你把url-pattern配成了/*那无论怎么配置静态资源处理都不太管用因为容器已经把请求全都交给 SpringMVC 了。遇到这类问题先检查 web.xml 或 Initializer 里的getServletMappings()。5.4 参数接收不到400 与 404 背后的原因完全不一样参数问题是最容易被误判成“路径映射不对”的坑。比如前端传了参数后端却拿到 null或者直接报 400 Bad Request。我常用的排查顺序是确认方法参数上有没有写注解。没写RequestParam在 SpringMVC 里拿不到简单类型参数通常会报运行时异常。确认参数名与请求参数名一致。用 Maven 打包时如果没有开启调试参数信息可能导致基于参数名的映射失效这时候最好显式写出RequestParam(name)而不是依赖RequestParam的默认名解析。确认参数类型是否匹配。前端传abc给int类型参数HandlerAdapter 在做类型转换时失败直接返回 400。确认required属性的默认行为。RequestParam默认requiredtrue参数缺失会直接报 400如果需要可空参数应该显式设置requiredfalse而不是去前端规避。还有一个容易忽略的细节PathVariable和RequestParam不要混用。前者从 URL 路径里取参数后者从查询字符串或表单体里取参数。如果路径变量名和方法参数名不一致而你又没有显式指定 value解析就会失败。我建议所有映射相关注解都显式写出名字能省掉非常多隐式解析带来的麻烦。5.5 依赖版本不一致导致的诡异问题还有一个不那么好排查的问题是 Spring 各模块版本不一致。很多人从网上下载依赖时没有锁定版本导致spring-webmvc是 5.x而spring-context被传递依赖拉到了 4.x。启动时如果不出错运行到某个需要新 API 的功能时才会爆炸。比如EnableWebMvc注册的组件在新的 SpringMVC 和旧的 Spring 之间可能对不上方法签名不一致直接抛NoSuchMethodError。我的建议是固定一个统一版本Spring 家族模块全部用同一个 release 版本号不要自己去混搭。这不算高级技巧但确实是我看过最多次的低级事故。另外提一句编码问题如果接口返回中文乱码优先在容器层面配置CharacterEncodingFilter并把forceEncoding设为true这样请求和响应都能统一编码。否则就算 Controller 里设置了produces application/json;charsetUTF-8也只能保证响应这一侧请求参数解析还是可能因为容器默认编码不是 UTF-8 而出问题。最后一点个人体会从整个 DispatcherServlet 的处理链路来看SpringMVC 的核心设计并不复杂它只是把“分发”这个动作拆成了很多小步骤映射查找、参数解析、方法调用、异常处理、视图渲染。初学时你会觉得这些组件太多了记起来特别吃力。我自己的经验是先把doDispatch()的主干流程背下来再去逐个熟悉旁边这些“策略接口”遇到问题顺着主干往分支排查比死记硬背配置项高效得多。如果你今天照着前文的 Demo 搭了一个能运行的 SpringMVC 项目下一步可以自己试着往里面加一个拦截器在HandlerMethod调用之前打印请求路径和参数然后观察控制台输出。这样你能更直观地感受到请求是怎么经过总闸、再到具体方法的。等这一条链路完全印在脑子里再看 SpringBoot 的自动配置时你会发现很多原来觉得神奇的东西其实还是这一套老骨架在支撑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑