☰
Spring Web MVC底层原理与实战:一次HTTP请求的完整旅程
2026/10/12 14:47:54 网站建设 项目流程

做Java后端时间久了,会发现真正决定一个人天花板高度的,往往不是那些炫技的中间件,而是最基础的那层框架理解。Spring Web MVC就是这样一门“外功”:它不复杂,但几乎所有Java Web项目都绕不开;它也不难学,但一旦理解得浅,后面加权限、加参数校验、处理文件上传、对接前端联调,全是糊涂账。这篇我从“它到底解决什么问题”讲起,顺着一次HTTP请求的完整链路,手把手搭一个最小可运行项目,再聊聊参数绑定、JSON处理、视图渲染这些高频用法,最后把新手最常见的坑和排查思路整理出来。适合刚学完Java基础、准备进入框架阶段的同学,也适合已经用Spring Boot写过业务、想回头把MVC底层梳理清楚的开发者。

1. 先想明白:Spring Web MVC到底解决什么问题

1.1 回到Servlet时代,看看曾经的痛点

早几年写Java Web,流程几乎是固定的:写一个类继承HttpServlet,重写doGet或doPost,然后用getParameter取前端传来的值,用PrintWriter往响应里拼HTML,最后手动处理转发或重定向。一个简单的用户列表页面,代码里一半是业务逻辑,另一半是HTTP协议细节:字符集要自己设置,响应头要自己写,参数类型转换要自己判断,更麻烦的是,如果项目稍微大一点,Servlet之间互相跳转的路径全靠硬编码,改一个入口就要全局搜索。

那时候最难受的不是某个功能写不出来,而是每个功能里都塞满了重复样板代码。就像你每天去菜市场买完菜,还要自己洗菜、切菜、配菜、生火,菜式一多厨房必然乱。Spring Web MVC就是那个帮你把厨房流程规范化的角色:谁来接单、谁来洗菜、谁来掌勺、谁来装盘,都有明确分工。它带来的不是一个“魔法”框架,而是一种可预期的、可以被拦截和扩展的处理流程。

1.2 为什么说它是Java开发的“外功基础”

把Java技术栈比作一棵树,根和主干是语言本身以及底层运行原理,也就是所谓内功;而Spring Web MVC这套请求分发机制,属于看得见、用得着的“外功”。很多开发者直接跳进Spring Boot,一键生成项目,发现注解一写接口就能跑通,反而容易忽略背后这套MVC框架自身的价值。但Spring Boot再好,底层核心依然是Spring Web MVC。你写的@RestController、@GetMapping,最终都会被DispatcherServlet和一系列组件处理。

所以把Spring Web MVC吃透,再学Spring Boot就是水到渠成的事:Boot只是帮你把容器、配置、装配自动化了,并没有改变请求到达之后的处理逻辑。换句话说,MVC是地基,Boot是装修;地基打不牢,装修再漂亮也住不安稳。这也是为什么我一直建议新人先手动搭一次MVC项目,别一上来就依赖初始向导。手动搭过一遍,你才真正知道那个“自动配置”替你做了多少事。

1.3 核心组件分工:谁负责调度,谁负责执行

Spring Web MVC构建在“前端控制器”模式上。核心是DispatcherServlet,它接收所有请求,然后通过一系列协作组件把请求分发到合适处理器。简单罗列一下这些组件就足够直观:

组件核心职责
DispatcherServlet接收所有请求,统一调度,是MVC的核心入口
HandlerMapping根据URL、HTTP方法等信息,找一个能处理当前请求的Handler
HandlerAdapter真正调用Handler的适配器,负责参数解析、返回值处理
Controller写业务逻辑的地方,也是我们日常编码最多的类
ViewResolver当需要返回页面时,根据逻辑视图名解析出具体视图模板
View负责最终页面渲染,输出到响应流

用一个日常场景来类比:DispatcherServlet是前台接待,HandlerMapping是查“这个客户应该找哪个部门”的导办人员,HandlerAdapter是具体请你坐下、帮你把需求录进系统的业务专员,Controller才是真正干活的业务专家,ViewResolver则是最后跟你说“您的文件已经排版好,可以拿走了”。这个类比虽然简单,但能帮你把六个组件之间的协作关系记得很牢。

2. 一次HTTP请求的完整旅程:从URL到响应

2.1 第一站是Servlet容器,并不是Controller

浏览器发出一个GET请求后,请求并不会直接飞到Spring的代码里。它先进入Tomcat等Servlet容器,容器根据URL匹配到对应的Servlet实例。在标准的Spring Web MVC配置里,这个Servlet就是DispatcherServlet。可以把Tomcat想成一座写字楼的物业:门禁、电梯、楼层管理都由它负责。它收到访客申请后,先做最基础的分流,比如静态资源直接找对应楼层,动态请求则转给DispatcherServlet。

这里有个经常被忽略的细节:DispatcherServlet本身也是Servlet,但它的生命周期完全由容器管理。它在初始化阶段会创建自己的WebApplicationContext,也就是Spring容器,用来装载Controller、Service等所有Bean。DispatcherServlet并不是被Spring容器创建出来的,反而是它创建并持有了一个Spring容器,这个关系初学阶段容易被绕晕。如果你用Spring Boot,容器启动细节被隐藏了,但底层依然是这个模型。

2.2 HandlerMapping怎么找到你的Controller方法

请求进入DispatcherServlet后,第一步是询问HandlerMapping:“这个请求该找谁处理?”HandlerMapping会根据当前Request,遍历所有注册好的路由规则,最终返回一条HandlerExecutionChain。这个链条里除了Handler本身,还包括一串拦截器(HandlerInterceptor)。比如@GetMapping("/user/{id}")这类注解信息,在容器启动时就会解析成RequestMappingInfo,保存到映射表里。

匹配时会考虑很多条件:

  • URL路径是否一致,路径变量怎么提取
  • HTTP方法是否匹配(GET、POST、PUT、DELETE等)
  • 请求头、请求参数、Content-Type是否满足条件
  • 类上路径与方法上路径拼接是否正确

其中最容易被绊倒的是路径拼接:类上写了@RequestMapping("/user"),方法上写了@GetMapping("/list"),实际路径就是/user/list。斜杠多一个少一个,在容器启动时往往不报错,运行期却直接404,而且很难一眼看出来。

2.3 HandlerAdapter执行与拦截器介入

找到Handler后,DispatcherServlet不会直接调用它,而是通过HandlerAdapter来适配。为什么要多这一层?因为Handler的类型不只是注解式Controller方法,也可能是古老的Controller接口实现、HttpRequestHandler等。适配器模式让DispatcherServlet不需要关心Handler到底长什么样,统一走同一套调用逻辑。

在调用前,拦截器的preHandle方法会先执行。如果返回false,请求直接结束,Controller方法不会执行;返回true,才继续往下走。业务逻辑执行完成后,postHandle会被调用。最后在视图渲染完成后,afterCompletion负责收尾,通常用来清理资源或记录耗时。这一套就像洋葱一样层层包裹,理解了它,也就理解了Spring Web MVC的扩展点。

Controller方法执行完,通常有两种返回方式:

  • 加了@ResponseBody:返回对象直接由消息转换器写出到响应体,常见的是转成JSON。
  • 没加@ResponseBody:返回的字符串会被当作逻辑视图名,进入视图解析环节。

2.4 视图解析与响应写出

如果返回的是视图名,DispatcherServlet会交给ViewResolver。ViewResolver根据逻辑视图名找到具体的模板或JSP,再把Model里的数据填充进去,最终渲染成HTML。常见的有ThymeleafViewResolver、InternalResourceViewResolver等。你可以把ViewResolver理解成餐厅里“传菜员”,他只认菜名,不认做法;你说“西红柿炒蛋”,他就去后厨把对应菜品端出来。

到这里,一次请求才算完整结束。整体链路就是:请求进入Tomcat,转到DispatcherServlet,由HandlerMapping定位Handler,再经HandlerAdapter执行Controller方法,得到ModelAndView或JSON数据,最后通过ViewResolver渲染并返回响应。这条链路写下来只有几句话,但每个环节都是可替换、可扩展的接口。这也是Spring Web MVC最值得学习的地方:它把HTTP处理流程抽象成一条责任链,每一环都能替换实现,而不是把代码写死。

3. 手写一个最小可运行项目:从零搭通Spring Web MVC

3.1 工程结构、依赖选型与目录规划

我建议新手至少手动搭一次项目,而不是只点网页生成器。手动搭一次,你才知道DispatcherServlet是谁注册的、Spring容器从哪来的。这里我用Maven工程来演示,目录结构保持干净:

  • src/main/java,放Java源码
  • src/main/resources,放配置文件和资源
  • pom.xml,管理依赖

pom.xml里需要引入的最小依赖包括spring-webmvc、Servlet API,以及一个Servlet容器。如果你要返回JSON,还需要Jackson相关依赖;如果只是返回视图,则需要引入对应的模板引擎。先不引入模板引擎,用最小依赖把链路跑通,是理解MVC底层最好的方式。

3.2 注册DispatcherServlet并开启组件扫描

采用Servlet 3.0以上环境时,可以不写web.xml,而是写一个类继承AbstractAnnotationConfigDispatcherServletInitializer,让容器启动时自动注册DispatcherServlet并加载Spring配置:

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[] { "/" }; } }

这段代码的核心作用是:把DispatcherServlet映射到“/”,也就是所有请求都先经过它。RootConfig一般放公共配置,WebConfig放MVC相关配置。WebConfig里需要开启Spring MVC并扫描Controller所在的包:

@Configuration @EnableWebMvc @ComponentScan("com.example.web") public class WebConfig implements WebMvcConfigurer { }

注意,这里我用了com.example.web作为示例包名,实际项目中请替换成你自己项目的包结构。@EnableWebMvc会开启一系列默认配置,包括消息转换器、参数解析器等,相当于帮你把MVC的基础设施一次性装好。

3.3 编写第一个Controller并验证流程

Controller的代码非常简单:

@RestController @RequestMapping("/demo") public class DemoController { @GetMapping("/hello") public String hello(@RequestParam(defaultValue = "world") String name) { return "hello, " + name; } }

这里每个元素的含义都值得说一句:@RestController告诉Spring这个类是Controller,并且返回结果直接写入响应体,不需要再做视图解析。@RequestMapping("/demo")是类级别的URL前缀,@GetMapping("/hello")限定GET方法并映射到/hello。@RequestParam(defaultValue = "world")表示参数name不是必填项,没传就默认是world。

把项目跑起来后,在浏览器访问http://localhost:8080/demo/hello?name=java,响应就是hello, java。此时再去翻启动日志,你会看到Spring打印了类似“Mapped {[/demo/hello], methods=[GET]}"的记录,这条日志就是HandlerMapping在启动阶段构建路由表的证据。看到这条日志,才算真正把请求链路和代码对上了。

4. 开发中的高频细节:参数绑定、JSON与视图渲染

4.1 参数绑定的常用写法与匹配规则

日常开发中,参数绑定是最频繁接触的部分。最常用的有三种:@RequestParam绑定查询参数,@PathVariable绑定路径参数,@RequestBody绑定请求体JSON对象。三者各有适用场景,不要混用。

@GetMapping("/user/{id}") public UserVo getUser(@PathVariable Long id, @RequestParam(required = false) String keyword) { return userService.getUserById(id, keyword); } @PostMapping("/user") public UserVo createUser(@RequestBody CreateUserRequest request) { return userService.create(request); }

需要注意几点:@PathVariable如果名称和参数名不一致,要写value属性,比如@PathVariable("user_id") Long userId。@RequestBody特别容易因为请求头Content-Type不对而报415,前端用JSON.stringify提交时一定要设置application/json。类型转换失败时通常返回400,比如你传了一个非数字给Long类型的参数,Spring会直接判定参数绑定失败。

另外,当GET参数比较多时,不必写一堆@RequestParam,可以直接用一个POJO对象接收。Spring会把请求参数按名称匹配到对象字段上,代码会清爽很多。这个技巧在查询类接口里非常好用。

4.2 写JSON接口的正确姿势

现代开发基本前后端分离,大部分Controller接口返回的都是JSON。只要引入了Jackson,并且方法使用@RestController或@ResponseBody,返回的对象就会被自动序列化成JSON。建议所有业务接口统一返回一个封装结构:

public class ApiResult<T> { private int code; private String message; private T data; public static <T> ApiResult<T> success(T data) { ApiResult<T> result = new ApiResult<>(); result.code = 0; result.message = "ok"; result.data = data; return result; } }

Controller里就可以这样写:

@GetMapping("/user/{id}") public ApiResult<UserVo> getUser(@PathVariable Long id) { return ApiResult.success(userService.getById(id)); }

统一返回结构最大的好处是前端解析格式固定,不用每个接口各自约定。如果不这样做,有的返回对象、有的返回Map、有的直接返回字符串,联调时前端代码会写得很痛苦。还有一个高频坑是时间格式:LocalDateTime默认序列化结果带毫秒,前端格式化很麻烦。建议在配置里统一指定:

spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=Asia/Shanghai

这样的配置能避免大量“时间显示不对”的线上问题。

4.3 返回视图时的注意事项

如果项目需要服务端渲染页面,那就要分清@Controller和@RestController。很多人一开始把两者混着用,结果发现返回字符串当成响应体输出了。简单说:@Controller是完整版MVC,返回String会被当视图名;@RestController等于@Controller加@ResponseBody,返回String直接写进HTTP响应体。

用Thymeleaf这类模板引擎时,模板文件放在src/main/resources/templates目录下,逻辑视图名不需要带前缀后缀,由解析器配置统一补齐。比如Controller返回"user/list",引擎会自动找templates/user/list.html。这里最容易犯的错是手贱把路径写全,比如返回"/templates/user/list.html",模板引擎反而找不到文件,启动不报错,访问才报错。

另外,如果接口既要返回页面又要返回JSON,可以用一个Controller类里分别写不同方法,但不要用同一个方法动态判断,那样维护成本很高。更好的做法是页面和接口分开,页面渲染走Spring Web MVC的视图解析,接口走@ResponseBody,逻辑更清晰。

5. 新手高频踩坑与排查思路实录

5.1 典型异常速查表

刚上手阶段,很多人会被那一堆HTTP状态码搞懵。下面这张表是我平时带人时常用的速查表,直接保存下来对照即可:

现象常见原因解决思路
404,接口明明写了却找不到路径拼接错误、大小写不一致、类没被扫描看启动日志里的Mapped记录
400 Bad Request参数必填项缺失、类型转换失败检查参数名、参数类型、是否少传参数
415 Unsupported Media Type@RequestBody收到的Content-Type不是JSON前端请求头改成application/json
500 Internal Server ErrorController内部异常或依赖服务异常看服务端日志堆栈,先定位异常类型
静态资源CSS/JS同样404DispatcherServlet拦截了静态资源请求配置资源映射或交给容器默认Servlet处理
JSON序列化失败对象里有不可序列化字段、循环引用检查字段类型、用@JsonIgnore或DTO代替实体

这里想单独强调404的排查顺序。遇到404,不要先猜是不是代码有问题。先确认应用有没有起来,再看启动日志里的路由表有没有你要访问的路径,然后看URL拼接是否符合类上和方法上的前缀规则,最后才考虑是不是被拦截器或过滤器拦掉了。按照这个顺序走,绝大多数404都能在几分钟内定位。

5.2 一次404问题的完整排查过程

之前在一个模拟项目X里,前端同学反馈新增的“用户导出”接口访问就404,接口代码怎么看都没问题。我接手后第一件事不是看代码,而是打开启动日志找路由信息,发现日志里根本没有这个接口的映射记录。说明Controller根本就没被扫描到,而不是路径写错了。

继续往下查才发现,原因是WebConfig的@ComponentScan包路径写的是com.example.web,但Controller实际放在com.example.module.user.controller下面,扫描范围没覆盖。把包路径改成com.example或者调整目录结构后,路由记录立刻出现,接口恢复访问。

这类问题的深层原因,是不理解容器初始化时“组件扫描”这件事。注解写得再对,类没有被扫描到,一切等于零。所以建议新人初期把所有Controller放在启动类同级的子包下面,等熟悉了再按模块拆分目录。

5.3 实战中总结的几个避坑习惯

第一,Controller方法不要做大事务。事务应该放在Service层,而且尽量控制短小,因为Controller本质上是HTTP入口,它要快速响应和释放线程。把大量数据库操作堆在Controller里,既不好测试,也难以复用。

第二,URL命名建议用连字符而不是驼峰和下划线,比如/user-export或/user/export,不要写/userExport。虽然Spring本身不做限制,但业界习惯和Nginx等代理工具对URL的解析、缓存匹配往往更倾向于连字符风格。这里看似细枝末节,后台系统多了以后,接口风格不统一非常影响维护。

第三,拦截器和过滤器不要混用。Filter由Servlet容器管理,在Spring容器初始化之前就可能执行;Interceptor由Spring MVC管理,只有经过DispatcherServlet的请求才会触发。要想在拦截器里注入Service,建议用Interceptor而不是Filter,因为Interceptor就是一个Spring管理的Bean,注入非常自然。

第四,实体类字段命名要小心boolean类型。如果你写了一个boolean isActive,很多JSON库序列化出去后,字段名可能变成active而不是isActive。避免这个坑最简单的办法是字段直接命名为active,对应getter写成isActive(),这是JavaBean规范里的标准做法。前端看到的JSON字段就稳定了。

第五,不要忽略统一异常处理。Controller里不要到处try-catch,更不要吞掉异常。推荐添加一个@RestControllerAdvice类来做统一异常处理,把业务异常、参数校验异常、兜底异常分别映射到统一返回结构。这样不仅代码整洁,出问题时日志和响应也都有规律可循。

最后说一点个人体会。我一直觉得框架学习的正确顺序不是“背注解”,而是“走流程图”。Spring Web MVC的难度不在代码量,而在于它把很多隐藏操作分散在请求的各个阶段。如果你现在刚学到这里,不妨做两件事:第一,手动搭一个最小项目,不用任何自动生成工具;第二,把日志级别调到DEBUG,随便发一个请求,盯着日志看从DispatcherServlet到Controller每个环节都做了什么。看一次,胜过读十篇博客。以后再接触Spring Boot、Spring Cloud,你会发现自己对“请求到底是怎么被处理”的这件事,始终有一份底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询