如果你做了几年 Java 后端开发,SpringMVC 绝对是绕不开的一座山。哪怕现在 Spring Boot 号称“零配置”开箱即用,脱掉那层自动配置的外衣,底层处理 HTTP 请求的还是 SpringMVC 这套机制。换句话说,SpringMVC 并没有过气,它只是换了个形态继续统治着 Java Web 服务端的入场规则。
这篇总结要做的,就是把 SpringMVC 从头到尾彻底捋一遍:架构是怎么设计的、一个请求进来后内部到底发生了什么、注解背后的处理逻辑是什么、异常和拦截器怎么玩、以及那些你早晚会踩进去的坑。内容取自我多年实际项目的使用经验和源码阅读笔记,不是官方文档的翻译。适合两类人:刚学完 Java 基础准备上手 Web 开发的同学,需要把框架脉络一次性理清;写了几年 CRUD 但对底层机制似懂非懂的老手,借这篇把知识体系补完整。
1. 先把架构看清楚:SpringMVC 到底干了什么事
1.1 没有框架的年代,开发有多痛
在 SpringMVC 普及之前,Java Web 开发的主流方式是写 Servlet。一个简单的表单提交,你要继承 HttpServlet,重写 doGet 和 doPost,手动调用 request.getParameter("username") 拿参数,手动 new 一个业务对象,用完还得手动调 request.getRequestDispatcher(...).forward(...) 跳页面。参数多了,校验逻辑写起来让人崩溃;业务一复杂,Servlet 类越来越多,每个类里还混着流程控制、业务调用和视图跳转,耦合得死死的。
我记得最初接手老项目时,一个用户注册功能横跨五六个 Servlet,每个 Servlet 里还有大段重复的参数获取代码。改一个字段,要把所有相关类翻一遍,漏一个就出线上事故。那个年代的痛点说白了就三条:参数解析靠手写、流程控制靠硬编码、视图跳转靠字符串拼接。SpringMVC 的诞生,本质上就是把这堆脏活累活全部收编到框架内部。
1.2 MVC 思想与前端控制器模式
SpringMVC 的核心设计思路其实就两个词:MVC 分层 + 前端控制器。
MVC 大家都很熟:Model 管数据,View 管展示,Controller 管调度。但 MVC 只是一种理念,真正落到 Web 框架上,还需要一个统一的入口来接收所有请求,这就是前端控制器模式。SpringMVC 里的前端控制器就是 DispatcherServlet,它像公司前台一样,所有请求必须先到这里报到,由它决定找哪个部门(Controller)处理,处理完再决定交给哪个部门(View)渲染。
这个设计的最大价值在于,业务开发人员不再需要关心请求是怎么被路由进来的,只需要写一个带注解的类,声明“我能处理 /user/login 这个地址”,剩下的分发逻辑交给前台。整个框架的可扩展性也大大提升,因为所有请求都经过一个入口,框架可以在入口处统一做拦截、参数解析、异常处理等横切逻辑。
1.3 核心组件分工一览
SpringMVC 的关键组件组合起来就是一张请求处理的流水线,先记住这张分工表,后面讲请求链路时你就不会迷路:
| 组件 | 职责 | 类比 |
|---|---|---|
| DispatcherServlet | 前端控制器,接收所有请求并分发 | 公司前台 |
| HandlerMapping | 根据 URL 找到对应的 Controller 方法 | 通讯录 |
| HandlerAdapter | 调用 Controller 方法,适配参数 | 传话筒 |
| Controller | 业务处理入口,返回 ModelAndView 或数据 | 业务部门 |
| ViewResolver | 根据视图名解析出具体视图 | 打印店 |
| View | 渲染页面输出给浏览器 | 成品文件 |
| HandlerExceptionResolver | 统一处理异常 | 售后部门 |
搞清楚这张表,你就已经掌握了 SpringMVC 的百分之五十。剩下的百分之五十,全是围绕这七个角色展开的细节。
2. 请求一生的旅程:从 URL 到响应全链路拆解
2.1 一切的起点:DispatcherServlet 启动与配置
DispatcherServlet 本质就是一个 Servlet,所以它的生命周期由 Web 容器管理。在传统 SSM 项目里,你需要在 web.xml 里配置它,并指定 Spring 配置文件的加载路径。后来 Servlet 3.0 规范引入了注解配置,可以用 AbstractAnnotationConfigDispatcherServletInitializer 这个基类,重写 getServletMappings 和 getRootConfigClasses 方法,用纯 Java 方式完成注册。
Spring Boot 出现后,这一步被自动配置类彻底接管。你引入 spring-boot-starter-web 依赖后,Boot 会自动注册 DispatcherServlet,默认映射路径是 “/”。很多新人会用 Spring Boot 用得很溜,却完全不知道 DispatcherServlet 的存在,这就是框架封装太好的副作用。我的建议是:哪怕你现在只用 Spring Boot,也一定要在传统配置方式下亲手搭一次 SpringMVC 环境,因为它能让你理解 Boot 帮你省掉的到底是什么。
2.2 HandlerMapping 怎么找到你的 Controller 方法
请求进来后,DispatcherServlet 会遍历容器里所有 HandlerMapping,问它们“谁能处理这个请求”。最常用的 RequestMappingHandlerMapping 会解析所有 @RequestMapping 注解,构建出一个 URL 到 HandlerMethod 的映射表。这个映射表可不是简单存一个方法引用,它还会记录方法参数、方法注解、类级别的路径前缀等信息,为后续调用做准备。
匹配规则需要多说一句:SpringMVC 支持精确匹配、路径变量匹配、通配符匹配,以及 consumes 和 produces 条件匹配。实际开发中,我见过不少因为路径变量和精确路径冲突导致请求路由错乱的情况。比如同时定义 @GetMapping("/user/{id}") 和 @GetMapping("/user/me"),如果请求 /user/me 时,框架的匹配优先级处理不当,就可能进错方法。Spring 的匹配策略是精确匹配优先于路径变量,但如果你在多个 HandlerMapping 之间搞混淆,排查起来还是很费劲的。
2.3 HandlerAdapter:参数是怎么被填进去的
找到 HandlerMethod 之后,DispatcherServlet 会找一个支持它的 HandlerAdapter,默认是 RequestMappingHandlerAdapter。这个组件干的事很神奇:框架根据方法签名里的参数类型和注解,自动把 HTTP 请求里的内容转换成方法参数。
比如你写 public String update(@PathVariable("id") Long id, @RequestParam("name") String name),HandlerAdapter 在执行方法前,会先调用参数解析器集合(HandlerMethodArgumentResolver),逐个判断“这个参数我能解析吗”,能就解析,不能就跳过。常见的内置解析器有 ServletRequestMethodArgumentResolver、PathVariableMethodArgumentResolver、RequestParamMethodArgumentResolver、RequestBodyMethodArgumentResolver 等。
理解这一点对排查问题特别重要。很多新人遇到“为什么我的参数是 null”“为什么参数类型转换报错”,根本不知道去查参数解析器,只会怀疑是前端传参格式不对。其实只要搞懂每个解析器的触发条件和优先级,这类问题五分钟就能定位。
2.4 视图渲染与响应输出
Controller 方法执行完,传统模式下会返回一个 String 类型的视图名,DispatcherServlet 再交给 ViewResolver 解析成真正的 View 对象。ViewResolver 有很多实现类,最常用的是 InternalResourceViewResolver,它的核心配置就两个属性:前缀 prefix 和后缀 suffix。比如配置了 prefix = "/WEB-INF/views/" 和 suffix = ".jsp",那么方法返回 "user/login" 时,框架就会去找 /WEB-INF/views/user/login.jsp 这个文件。
这里有个很关键的细节:加了 @ResponseBody 的方法不走视图解析。框架会在返回阶段检查方法或类上有没有 @ResponseBody 注解,有的话就调用 HttpMessageConverter 把返回值序列化成 JSON 字符串直接写进响应体。所以“返回视图”和“返回数据”两条路径是不同的,不要混为一谈。
3. 注解驱动的 Controller:你每天都在写的那些注解到底怎么工作
3.1 类级与方法级映射
@Controller 注解标记一个类是控制器,它的本质是把类注册成 Spring 容器中的一个 Bean,同时标记为 Web 层的处理器。
@RequestMapping 可以写在类上,也可以写在方法上。类上的路径相当于模块前缀,方法上的路径是具体接口地址,两者拼接后才是完整的访问路径。很多团队规范里会要求类级别统一定义 /api/v1 这样的版本前缀,方法上只写资源路径,这样接口文档看起来非常清晰。
从 Spring 4.3 开始,框架提供了 @GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping 这些组合注解,本质是 @RequestMapping 加请求方法限定的语法糖。我强烈建议方法级别用这些语义化注解,因为光看注解就知道这个接口是干什么用的,而且能避免忘记指定 method 属性导致同一路径被多个方法映射的冲突。
3.2 参数接收的完整武器库
接收参数的注解看起来简单,但选错一个就会让你多写十行代码。把常用场景整理成一张表:
| 注解 | 适用场景 | 参数来源 |
|---|---|---|
| @RequestParam | 单个查询参数或表单字段 | URL 查询串、表单 body |
| @PathVariable | RESTful 风格 URL 中的路径段 | URL 路径 |
| @RequestBody | JSON 或 XML 请求体 | 请求体 |
| @RequestHeader | 请求头信息 | 请求头 |
| @CookieValue | Cookie 值 | Cookie |
| @ModelAttribute | 表单数据自动绑定到对象 | 查询串、表单 body |
| @SessionAttribute | 从 Session 中取值 | Session |
实际项目中有一个高频场景:前端用 GET 传多个查询条件。你要么写一堆 @RequestParam,要么直接用 @ModelAttribute 接一个查询对象。我倾向于后者,因为条件多了以后,方法签名会变得非常臃肿,而一个查询对象可以同时承担参数载体和后续的 MyBatis 查询条件两个角色。
还有一种场景是 POST 提交 JSON,用 @RequestBody 接收。这里要注意,@RequestBody 和 @RequestParam 不能混用于同一个参数位置,一个方法里两种格式可以并存,但前端每只只能选择一种提交方式。JSON 请求体用的是 application/json,表单提交用的是 application/x-www-form-urlencoded,如果你没改 Content-Type 却用 @RequestBody 接收,大概率会得到 415 错误。
3.3 @ResponseBody 与 JSON 序列化的真相
@ResponseBody 的核心是 HttpMessageConverter。默认情况下,SpringMVC 会注册一组转换器,其中有处理 String 的 StringHttpMessageConverter,处理字节数组的 ByteArrayHttpMessageConverter,处理 XML 的 Jaxb2RootElementHttpMessageConverter,以及处理 JSON 的 MappingJackson2HttpMessageConverter。
这里经常出问题的点是:项目里如果没有引入 Jackson 依赖,那么 JSON 转换器就不会注册,@ResponseBody 返回对象时就会报 HttpMediaTypeNotAcceptableException 或 406 错误。另一个点是 Jackson 对 Java 8 新时间类型的支持,LocalDateTime 默认序列化出来是一段数组,必须引入 jackson-datatype-jsr310 模块并注册 JavaTimeModule,再配置日期格式,才能输出成正常的字符串。
还有一个小坑很多人中招:实体类里有 Date 属性,默认序列化出来是时间戳数字,前端拿到一脸懵。解决办法是加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") 注解。加上 timezone 是因为不指定时区的话,Jackson 默认按 GMT 时间序列化,你会发现自己存进去的时间和返回出来的时间差了 8 个小时。
4. 数据绑定、类型转换与参数校验:Controller 的“质检”环节
4.1 数据绑定的完整流程
SpringMVC 的数据绑定,是把请求参数映射到 JavaBean 属性的过程。以 @ModelAttribute 为例,框架的执行顺序大概是:先通过默认构造器创建一个目标对象,然后遍历请求参数,对每个参数调用类型转换器转成目标属性的类型,再通过反射调用 setter 写入对象。
这个过程中最容易出问题的是类型转换。比如请求参数 “age=25abc”,目标属性是 Integer,框架会调用 StringToNumberConverterFactory 做转换,转换失败时会抛出 TypeMismatchException。默认情况下,这个异常会被框架包装成 400 错误返回。如果你没有自定义异常处理,前端拿到的就是一个不太友好的错误页面,所以后面讲异常处理时会特别强调全局处理器的必要性。
4.2 自定义类型转换器实战
内置转换器覆盖了基本类型、包装类型、日期类型等,但实际业务里总有它管不着的情况。最典型的例子是前端传的日期字符串格式五花八门,“2024-01-01”“2024/01/01”“01-01-2024”都有。Spring 默认只认 “yyyy/MM/dd” 格式,你前端传 “2024-01-01” 就会报错。
解决办法有三种:一种是写 @InitBinder 方法,在 Controller 内部注册自定义的编辑器;第二种是写一个自定义的 Formatter 并注册到容器;第三种是全局配置一个 Converter。三种方案各自的适用场景不一样:@InitBinder 只作用于当前 Controller,适合局部特殊处理;Formatter 和 Converter 可以在全局生效,适合项目级统一格式约定。我个人习惯用 Converter,因为它的抽象更简单,一个类搞定一种类型的全局转换,不需要额外配置。
自定义 Converter 的写法就是实现 org.springframework.core.convert.converter.Converter 接口,在 convert 方法里解析字符串并返回目标对象。然后把它注册到 WebMvcConfigurer 的 addFormatters 方法里,框架就会在所有参数绑定场景中使用它。
4.3 参数校验:从手动 if 到声明式校验
以前写参数校验,都是在一堆 if 语句里做非空判断,代码又臭又长。SpringMVC 从 3.x 开始支持 JSR-303 Bean Validation 规范,配合 Hibernate Validator 实现,你可以直接在实体类字段上加校验注解,然后在 Controller 方法参数上加 @Valid 注解触发校验。
常用注解有 @NotNull、@NotBlank、@Size、@Min、@Max、@Email、@Pattern 等。校验失败的默认行为是抛出 MethodArgumentNotValidException,如果你不处理,前端会收到 400 和一个结构混乱的错误信息。
处理校验错误信息有个技巧:校验注解的 message 属性支持占位符,你可以写 {field.required} 这种 key,然后在 ValidationMessages.properties 属性文件里配置真正的提示文案,实现提示信息国际化。还有一个进阶玩法是利用分组校验,比如新增时要求 id 为空、更新时要求 id 必填,通过 @Validated(InsertGroup.class) 指定不同场景的校验规则。这个功能用好了,一个实体类能应对多种业务场景,不用为了校验规则差异维护多套 DTO。
5. 全局异常处理与拦截器:把横切逻辑收拢起来
5.1 @ExceptionHandler 与 @ControllerAdvice 的正确组合
没有异常处理机制的 Controller 层是灾难。默认情况下,Controller 抛出的异常会一路传到容器,最终返回一个 500 错误页面,前端调试毫无头绪。更麻烦的是,如果异常发生在事务方法里,你还得小心翼翼处理回滚,不然数据就处于一个不可知的状态。
全局异常处理的方案现在基本是统一的:创建一个类,加上 @ControllerAdvice 注解,然后在类里面写多个 @ExceptionHandler 方法。@ExceptionHandler 注解的值指定要处理的异常类型,方法参数可以用 Exception 接收原始异常,返回值和普通 Controller 方法一样,支持返回视图名或 JSON 数据。
我曾经在一个项目里按要求用 @RestControllerAdvice 统一返回 JSON 格式的错误码,结构是 code、message、data 三段式。这样前端无论遇到什么错误,都能按统一结构解析。这里分享一个实战原则:异常处理不是把所有异常都吞掉返回 200,而是既要把错误信息结构化,又要保留排查问题的能力。比如返回给前端的 message 可以友好一点,但完整的堆栈必须记录在日志里,所以我习惯在 @ExceptionHandler 方法里先 log.error,再返回响应。
还需要注意 @ExceptionHandler 的匹配规则:如果同一个异常被多个方法匹配,Spring 会优先选择最具体的异常类型。所以你可以写一个通用的 Exception 处理方法兜底,但针对业务异常、参数校验异常单独写处理方法,这样既保证可控性,又不至于所有错误都统一成一种格式。
5.2 HandlerInterceptor 拦截器和过滤器 Filter 的区别
拦截器和过滤器是 Web 开发中两个最容易混淆的概念。Filter 是 Servlet 规范里的东西,在 DispatcherServlet 之前执行,作用范围是整个 Web 应用,配置方式是 WebFilter 注解或者注册 FilterRegistrationBean。HandlerInterceptor 是 SpringMVC 框架的组件,只能在 SpringMVC 处理链路中执行,而且只拦截经过 DispatcherServlet 分发的请求。
拦截器的典型应用场景有登录鉴权、接口日志记录、性能监控、请求头上下文注入。实现方式是实现 HandlerInterceptor 接口,重写 preHandle、postHandle、afterCompletion 三个方法。preHandle 在 Controller 执行前调用,返回 false 可以中断请求;postHandle 在 Controller 执行后、视图渲染前调用;afterCompletion 在视图渲染完成后调用,适合做资源清理。
注册拦截器要继承 WebMvcConfigurer 并重写 addInterceptors 方法。有一个细微但重要的点:拦截器的执行顺序是注册顺序决定的,多个拦截器的 preHandle 按正序执行,postHandle 和 afterCompletion 按逆序执行。这类似于一层层洋葱,理解了这个顺序,你排查日志顺序时就不会一头雾水。
另外一个实用经验:拦截器里拿到的 HttpServletRequest 是原始对象,如果要修改请求体或响应体,直接包装并传入 chain.doFilter 或 mv 是不行的,必须使用装饰器模式包装后透传给后续链路,而且要注意包装后的对象在异步请求场景下会被释放,需要额外处理。这些细节坑踩过一次你就知道,写拦截器之前先想清楚到底要改什么。
6. 高频故障排查实录:这些年踩过的 SpringMVC 坑
6.1 404 排查思路:一堆人在这里浪费了几天
404 是 SpringMVC 最常见的故障,但 404 的原因差别很大。第一步先分清是容器层面的 404 还是 SpringMVC 层面的 404。访问静态文件或根本不存在的路径,Tomcat 返回的 404 页面是不一样的;SpringMVC 内部匹配失败返回的也是 404,但日志里会有 No mapping found 之类的提示。
常见的 404 原因包括:web.xml 里 DispatcherServlet 的 url-pattern 配置错了;注解没被扫描到,包路径配置漏了;Controller 类没加 @Controller 注解;方法映射路径拼写错误;Spring Boot 下没引入 web 启动器导致 DispatcherServlet 根本没注册。我还遇到过一次很隐蔽的情况:项目里存在两个映射路径相同的 Controller 方法,其中一个被 @RequestMapping 类前缀挡住了,导致另一个方法永远匹配不到,请求一直 404。遇到这种情况,建议打开框架的映射日志,Spring Boot 里设置 logging.level.org.springframework.web=DEBUG,就能在启动日志里看到所有已注册的 RequestMapping 列表。
6.2 中文乱码:从请求到响应的全链路检查
中文乱码几乎是每个 Java Web 新人必踩的坑。乱码问题要分方向排查:请求参数乱码、响应输出乱码、JSON 返回乱码、文件上传文件名乱码,来源各不相同。
请求乱码的根源是 Tomcat 默认用 ISO-8859-1 解码请求体。传统方案是配置 CharacterEncodingFilter,强制请求和响应使用 UTF-8,同时注意要把 forceEncoding 设为 true,否则只设置请求编码,响应编码还是默认值。Spring Boot 里可以通过 server.servlet.encoding.* 配置项控制,但要注意 Boot 的编码过滤器是有默认开关的,通常不会出问题。
响应乱码常见于手写 response.getWriter() 输出中文,或者老项目里 JSP 页面没有设置 pageEncoding。JSON 返回乱码也很经典:生产环境遇到的很多“返回的 JSON 里中文变成问号”,是 StringHttpMessageConverter 默认用 ISO-8859-1 编码输出字符串导致的,因为字符串转换器的默认编码不是 UTF-8。解决办法是注入一个新的 StringHttpMessageConverter 设置为 UTF-8,或者使用 produces = "application/json;charset=UTF-8" 显式声明。搜索引擎上这一条能搜出无数个提问,原因就是很多人没意识到这个默认编码的坑。
6.3 JSON 序列化相关的一堆坑
JSON 相关的问题是最消耗开发时间的。这里把高频问题集中梳理一下:
第一,无限递归。实体类有双向关联时,比如订单引用用户、用户又引用订单列表,Jackson 序列化时就会无限循环,最终抛异常或栈溢出。解决办法是 @JsonIgnoreProperties 忽略指定属性,或者用 @JsonManagedReference 和 @JsonBackReference 管理双向引用。但根治方案其实是在 DTO 层解决,返回给前端的数据不要直接甩出实体类。
第二,多态序列化丢失。基类引用指向子类对象时,默认序列化只会输出基类字段。如果确实需要多态,要用 @JsonTypeInfo 注解指定类型信息字段,或者干脆用 Map 结构拼装。
第三,null 值处理。前端对 null 和缺失字段的判断不一致时,可以配置全局 Include.NON_NULL 让所有 null 字段不输出。但注意,这会让前端拿不到字段导致 undefined,是报错还是不报错,需要前后端对齐,不能单方面改。
第四,超大数字精度丢失。Long 类型的 ID 超过 JavaScript 的 Number 安全范围时,前端拿到的数字会失真。解决方案是给 Long 字段加 @JsonSerialize(using = ToStringSerializer.class),序列化成字符串。
这些坑单独看都是小事,但合在一起,足以让一个团队在联调阶段耗掉两周时间。
6.4 静态资源被 DispatcherServlet 拦截的问题
这是一个非常经典的配置陷阱。当你把 DispatcherServlet 映射到 “/” 时,它默认会接管所有请求,包括静态资源。HTML、CSS、JS、图片统统进到 SpringMVC 的映射流程里,结果当然是没有对应 Controller,然后 404。
传统 XML 项目需要配置 mvc:default-servlet-handler/ ,Spring Boot 里默认已经做好了静态资源映射,默认目录是 classpath:/static/ 等几个位置,一般不会出问题。但如果你做过一些自定义路径映射的配置,比如自己实现了 addResourceHandlers,就要小心覆盖默认行为的问题。另外还有一个变体问题:接口路径和静态文件路径冲突,比如 /res/xxx,如果不做优先级区分,可能出现请求被静态资源处理器抢先处理的情况。
我处理这类问题的经验是:项目里统一约定所有接口以 /api 开头,静态资源保持默认路径,然后在路径规划时就彻底错开。好的规约能消灭一类问题,这比出问题后加配置修复要省心得多。
7. WebMvcConfigurer 全面定制:那些你没用过的扩展点
7.1 深入 WebMvcConfigurer 常用方法
SpringMVC 提供了一个强大的配置接口 WebMvcConfigurer,它把框架的所有扩展点都集中暴露出来。addInterceptors 注册拦截器、addCorsMappings 配置跨域、addResourceHandlers 定制静态资源映射、addFormatters 注册类型转换器、addArgumentResolvers 添加自定义参数解析器、configureMessageConverters 定制消息转换器、addViewControllers 做无逻辑路径跳转——这些方法覆盖了日常开发 90% 的定制需求。
尤其值得说的是 addCorsMappings。前后端分离项目绕不开跨域问题,服务器端在全球配置 CORS 是最标准的方式。很多人会在 Controller 上写 @CrossOrigin 注解,这也能解决问题,但如果一个项目有几十个接口,注解方式完全是体力活。用 addCorsMappings 统一配置 allowedOrigins、allowedMethods、allowedHeaders,一次解决所有接口的跨域。注意在生产环境,allowedOrigins 不要写 “*”,要精确配置允许的域名,否则任何来源都能跨域调用你的接口,安全隐患很大。
7.2 自定义参数解析器解决实际问题
WebMvcConfigurer 里最有技术含量的是 addArgumentResolvers。有时候你希望某些参数不用一个个传,而是框架自动解析。比如前端登录后携带 token,你希望每个需要用户信息的接口直接声明一个 UserInfo 参数,框架自动根据 token 从缓存里查出用户信息填进去。
实现方式就是自定义一个 HandlerMethodArgumentResolver,实现 supportsParameter 判断是否支持当前参数类型,然后实现 resolveArgument,从 request 中取出 token,解析出用户信息,返回对象。之后所有接口只要声明 UserInfo 类型参数,就自动注入,省去了一堆重复代码。这个方法在很多公司内部框架里都有实现,原理其实并不复杂,掌握之后你会发现自己对 SpringMVC 的控制力上了一个台阶。
同理,@RequestBody 的解析器也是通过 HandlerMethodArgumentResolver 机制实现的。理解了这一点,你甚至可以在团队里沉淀出一套自己的参数注入规范,把鉴权信息、操作日志上下文、租户信息全部通过参数解析器注入 Controller 方法,业务代码会干净很多。
8. 性能与规范:把 SpringMVC 用好和用对是两码事
8.1 Controller 层的设计规约
框架用熟了以后,真正的分水岭在于设计规范。我在实际项目里看到过太多反面案例:一个 Controller 类塞了几十个接口方法,一个方法里既做参数校验又做业务判断还直接操作 DAO,返回对象直接用数据库实体类。这种代码初期跑得通,但维护成本呈指数级上升。
我总结的经验是:Controller 只做四件事,接收参数、简单校验、调用 Service、封装返回结果。任何复杂的业务逻辑都下沉到 Service 层,任何跨表的操作都不该出现在 Controller 里。接口返回结构要统一,要么用 Result 包装类,要么在全局响应处理里统一封装,不要让每个方法随心所欲地返回裸对象。
接口版本管理也是重点。当一个接口的字段需要破坏性变更时,不要直接改原接口,而是在类路径上加 /v2,保留旧版本给存量客户端过渡。这是一个用路径约定解决兼容性问题的典型实践,比用参数区分版本要清晰得多。
8.2 性能优化:从参数解析到视图渲染
SpringMVC 性能优化有几个方向。第一是避免滥用 @RequestBody 接收大对象,因为 JSON 反序列化整个对象再逐字段校验,在大流量场景下是很可观的 CPU 开销。如果接口只需要表单里的几个字段,用 @RequestParam 或 @ModelAttribute 明显更省资源。
第二是视图渲染的选择。传统 JSP 模式在每次请求时都要执行视图编译和渲染,高并发下性能瓶颈明显。现在主流做法是前后端分离,后端只返回 JSON,由前端负责渲染,这本质上把 SpringMVC 的视图解析环节直接从服务端移除,性能自然是质的变化。
第三是合理配置异步处理。SpringMVC 从 3.2 开始支持 DeferredResult 和 Callable 实现异步接口,长轮询、消息推送场景可以释放容器线程。但异步不是万能的,滥用异步反而会增加复杂性,我见过有人把简单的查询接口改造成异步,结果线程池耗尽,性能不升反降。
8.3 与 Spring Boot 的融合:现代开发的最佳实践
现在几乎没人再从零搭建传统 SSM 项目了,Spring Boot 已经成为事实标准。但融合不等于替代,Spring Boot 只是用自动配置帮你把 SpringMVC 的组件装配好了。你在 Spring Boot 项目里写 Controller、用注解、配置拦截器,底层跑的还是 SpringMVC 那一套。
Spring Boot 对 SpringMVC 的帮助主要体现在几个方面:内嵌容器免去了部署麻烦,自动配置减少了 XML 纠缠,统一配置项管理替代了繁琐的组件定义,Actuator 还能直接暴露请求映射信息方便排查。但要注意,Boot 的自动配置有一个约定,就是你改了它可能失效。比如你自己定义了一个 WebMvcConfigurer 并且标注 @EnableWebMvc,会导致 Boot 的自动配置失效,静态资源映射全部失效——这个问题我见过不止一次,排查起来非常隐蔽,最终都要回到对 SpringMVC 工作机制的理解才能定位。
所以我的结论很明确:Spring Boot 是 SpringMVC 的现代化封装,你可以用 Boot 提高效率,但不能因为有了 Boot 就不学 SpringMVC。底层机制的理解,决定了你在框架出错时是被动搜答案,还是主动修问题。
9. 遇到问题怎么查:一套实用的定位方法论
9.1 开启日志与断点定位的时机
排查 SpringMVC 问题,日志是最重要的武器。Spring Boot 项目里把 logging.level.org.springframework.web 调到 DEBUG,你就能看到请求进入 DispatcherServlet、HandlerMapping 匹配、HandlerAdapter 调用、视图解析的全过程。
如果日志还不够,就上断点。常用的关键断点位置:DispatcherServlet 的 doDispatch 方法,看请求分发到哪个 Handler;RequestMappingHandlerAdapter 的 invokeHandlerMethod,看参数解析器做了什么;每个自定义参数解析器和转换器的实现方法里。很多参数莫名变成 null 的问题,在调试视图里看一下实际传入的 request 参数就能一目了然。
9.2 从异常信息反推组件链路
SpringMVC 的异常抛出来都是有规律的。MethodArgumentNotValidException 说明校验不过;NoHandlerFoundException 说明路由没匹配上;HttpMessageNotReadableException 说明 JSON 解析失败;TypeMismatchException 说明类型转换失败。每个异常都对应一个组件环节。所以看到异常不要慌,先想它是哪个环节抛出来的,再去查对应组件,命中率极高。
例如前端传来的 JSON 里日期格式是 “2024-01-01T10:00:00”,但你的 Jackson 没启用 JavaTimeModule,或者 LocalDateTime 反序列化器没有正确注册,就会得到 InvalidFormatException。许多新手在这一步就蒙了,其实拆开看就是“类型转换”这个环节出了问题,去找转换器配置就对了。
9.3 善用测试与复现技巧
接口出问题后,最快的复现方式是用 curl 或接口调试工具直接发起请求,绕开前端代码,排除前端干扰。比如 curl -X POST -H "Content-Type: application/json" -d '{"name":"test"}' http://localhost:8080/api/test,这样只看后端行为。
如果问题只出现在特定环境下,检查配置差异。本地能跑测试环境挂,十有八九是环境变量或配置文件不同。Spring Boot 的 profile 机制可以按环境加载不同配置,排查时用 --debug 启动参数或者检查 spring.profiles.active 是否生效。框架层面的问题,几十行代码就能写一个最小复现工程,丢到临时项目里跑一下,比在生产项目里反复试错高效得多。
最后分享一点个人心得。SpringMVC 这门技术,看着东西很多,但核心其实就那么几个:前端控制器、处理器映射、参数解析与转换、视图解析、异常体系。把这些概念吃透,剩下的全是围绕它们的排列组合。我做过很多带新人的项目,每次都说同一句话:框架可以帮你写代码,但框架不会帮你思考。遇到问题先想机制,再翻源码,最后才是搜方案。这个顺序反了,你会永远停在“会调但不懂”的层面。希望这篇总结能成为你系统掌握 SpringMVC 的一块垫脚石,下次再遇到相关问题时,你能少查几次搜索引擎,多一分从容。