SpringMVC这套框架,但凡做Java后端的人基本都绕不开。它不像Struts2那样配置繁重,也不像Servlet那样需要手动处理一堆底层重复逻辑,靠着一个DispatcherServlet把请求分发、参数绑定、视图渲染这些事全包了。我最早接触它的时候,最直观的感受就是:写控制器终于不再需要反复继承某个类或者实现某个接口,一个@Controller注解标注类,一个@GetMapping标注方法,就能把一个URL映射到业务逻辑上,代码干净不少。这篇内容我打算从架构设计的角度切入,再串一遍实际配置、请求流转、参数绑定、JSON交互、拦截器这些核心环节,最后把我踩过的坑和处理思路一并整理出来,希望能帮刚入坑的同学少走一些弯路,也适合用了一段时间但没系统梳理过的人查漏补缺。
1. 整体架构与一次请求的完整流转
1.1 核心组件阵容
先明确一点:SpringMVC不是凭空多出来的一套东西,它底层依旧建立在Servlet规范之上,只是把一个原本需要开发者自己管理的过程,全部交给了容器托管。整个框架的角色划分很清晰,我平时跟人讲的时候喜欢把它们比作一个公司里的各个部门:
- DispatcherServlet是前台接待,所有请求必须先进它这里。
- HandlerMapping是路由专员,负责根据请求路径找出对应的方法。
- HandlerAdapter是业务顾问,负责真正调用那个方法。
- ViewResolver是渲染部门,负责把逻辑视图名解析成具体页面或响应内容。
这些组件在传统的MVC项目里,很多都是XML文件里配置的。到了Spring Boot时代,框架自动装配帮我们省掉了大部分配置,但底层运作机制还是同一套。
1.2 DispatcherServlet的调度逻辑
当浏览器发出一个请求,比如GET /api/user/1,到达Tomcat之后,容器会根据web.xml中配置的servlet-mapping把请求交给DispatcherServlet。这个Servlet不是一个简单的服务端点,它天然就是一个前端控制器(Front Controller),职责是把请求往后分发。
一次完整的流转大概是这样一个顺序:
- 请求进入DispatcherServlet。
- 根据请求的URL,DispatcherServlet询问HandlerMapping,找出对应处理该请求的HandlerExecutionChain,这个链上除了我们自己写的Controller方法,还可能挂着拦截器。
- 拿到Handler之后,DispatcherServlet再交给HandlerAdapter去执行。Adapter会先做参数解析,把URL里的路径变量、请求参数、请求体里的JSON数据,绑定到Controller方法形参上。
- Controller方法执行完,返回一个ModelAndView或其他类型的返回值。
- 如果是返回视图,ViewResolver解析逻辑视图名,把Model中的数据渲染到对应的页面中;如果返回的是数据类内容,比如@ResponseBody标注的方法,则直接通过消息转换器写出JSON。
这个流程里最关键的一点是:Controller方法本身并不知道自己是如何被调用的,它只需要关注业务逻辑。参数怎么来、返回值怎么处理,都交给框架层完成。这也是SpringMVC能被广泛接受的原因,职责分离得很干净。
1.3 为什么是前端控制器模式
很多初学者会问:我不用DispatcherServlet,直接用Servlet映射到不同的Controller行不行?当然行,但你会发现每个Servlet要自己处理URL解析、参数获取、类型转换、异常处理、转发重定向这些重复工作,编码效率很低,而且所有代码都耦合在一起,后期很难维护。
前端控制器模式的核心价值在于:把公共逻辑上收,把差异逻辑下发。DispatcherServlet只负责调度,具体业务方法仍然由开发者维护。这样你在新增一个接口时,不需要新建Servlet类、修改web.xml,只需要加一个Controller方法,映射一个URL,剩下的全交给框架。
我在做项目时体会更深的是:这个模式让团队代码风格保持一致。无论谁写Controller,参数绑定方式、返回值约定都统一,review代码的时候省了很多精力。这也是我后来在技术选型时依旧倾向SpringMVC而非轻量路由框架的原因之一,规矩定好了,团队协作就顺了。
2. 环境搭建与核心配置实操
2.1 Maven依赖与web.xml配置
抛开Spring Boot的自动配置不谈,先看传统的XML配置方式,因为理解了这套手动装配过程,很多“Spring Boot里怎么就好了”的困惑就会豁然开朗。
一个基础的Maven工程,引入spring-webmvc这一个依赖就够了,它会传递带来Spring核心相关的包:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.32</version> </dependency>然后在web.xml里配置DispatcherServlet:
<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这里有一个我早期经常踩的坑:url-pattern用/*还是/,效果完全不同。/*会拦截所有请求,包括JSP和静态图片,这会导致JSP页面无法正常渲染显示成源码,或者静态资源404。/是默认Servlet路径,DispatcherServlet接管除了JSP以外的请求。所以项目里的JSP尽量放到/WEB-INF/目录下面直接由容器处理,业务请求都走DispatcherServlet。
2.2 注解驱动与配置类方式
spring-mvc.xml里的核心配置,也就是开启SpringMVC注解支持的部分:
<context:component-scan base-package="com.example.controller"/> <mvc:annotation-driven/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>component-scan是让SpringIOC容器管理Controller、Service这些类;annotation-driven则注册了RequestMappingHandlerMapping、RequestMappingHandlerAdapter这些核心处理器,没有它,你写的@Controller、@RequestMapping统统不生效;ViewResolver配置则告诉框架,Controller方法返回字符串"user/list"时,实际访问的是/WEB-INF/views/user/list.jsp。
后来用Servlet 3.0+环境的时候,我更喜欢用配置类,连web.xml都可以去掉:
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class<?>[]{RootConfig.class}; } @Override protected Class<?>[] getServletConfigClasses() { return new Class<?>[]{WebMvcConfig.class}; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } }这种方式的好处是配置变成Java代码,编译期就能检查错误,不像XML要运行起来才报错。建议新项目直接从配置类起步。
2.3 视图解析器的参数逻辑
InternalResourceViewResolver是传统JSP项目里最常见的选择。它内部维护了prefix和suffix两个属性,两者拼接成一个完整的路径。它的解析逻辑其实很简单:把Controller方法返回的字符串当作逻辑视图名,用prefix包前缀、suffix包后缀,然后通过RequestDispatcher转发到目标页面。
我在用的时候会注意一点:InternalResourceViewResolver还有个redirect和forward前缀机制。Controller方法里如果返回"redirect:/user/list",框架会识别到redirect:前缀,直接发送一个302重定向,而不是走视图解析器拼接路径。这个特性在处理表单提交后跳转,非常顺手,可以避免刷新页面重复提交的问题。
视图解析器最好放在配置文件的最后,由于DispatcherServlet维护了一个ViewResolver列表,框架会按顺序匹配,如果前面的解析器能处理就返回,处理不了才轮到下一个。实际项目中如果同时存在多种视图形态,比如JSP配合JSON,控制好顺序就能避免歧义。
3. 请求参数绑定与数据流转
3.1 参数绑定的底层机制
Controller方法的形参是怎么被赋值的?这个问题我曾经也懵了很久。其实核心在HandlerAdapter的一组参数解析器(HandlerMethodArgumentResolver)上。SpringMVC预制了几十种解析器,各自支持不同类型的参数,比如@PathVariable注解的参数由PathVariableMethodArgumentResolver处理,带@RequestBody注解的参数由RequestResponseBodyMethodProcessor处理。
参数绑定的大概过程是:
- HandlerAdapter拿到当前要调用的方法,逐一遍历方法形参。
- 根据每个形参上的注解、类型、参数名,找到合适的解析器。
- 解析器从request对象里取值,做类型转换,构造出参数。
- 全部参数解析完成,反射调用Controller方法。
这个机制很灵活。你如果想自定义一个解析器来处理某个特殊类型的参数,只需要实现HandlerMethodArgumentResolver接口,注册到配置里,框架就会在合适的时机调用它。我平时用的不多,但在做权限用户注入的时候,确实会自定义解析器,把请求头里的token解析成一个当前用户对象,直接注入Controller方法,省掉每个接口里手动获取token再查一遍用户信息的重复劳动。
3.2 常用注解与类型转换
参数绑定相关的注解,我在实际项目里最常用的是这几个:
| 注解 | 作用场景 | 示例 |
|---|---|---|
| @PathVariable | 从URL路径中取值 | /user/{id} 取id |
| @RequestParam | 从查询参数或表单取值 | ?pageNum=1 取pageNum |
| @RequestBody | 从请求体中反序列化JSON | 接收整个对象 |
| @ModelAttribute | 绑定表单字段到实体对象 | 表单提交用户数据 |
| @RequestHeader | 从请求头取值 | 取Authorization |
| @CookieValue | 从Cookie中取值 | 取sessionId |
类型转换这块,SpringMVC默认支持很多基础类型的自动转换,比如String转Integer、String转Date(需要格式匹配)。复杂一点的,比如把一个yyyy-MM-dd格式的字符串转成Date,推荐在实体字段上用@DateTimeFormat注解:
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime;如果你需要自定义转换逻辑,可以实现Converter接口,然后注册到ConversionService里去。我在项目里碰到过一个场景:前端传一个"1,2,3"这样的字符串,后端想直接接收成List,自定义了一个StringToListConverter,比在代码里手动split再遍历清爽得多。
3.3 集合嵌套与格式化经验
参数绑定到复杂对象时,命名规范很重要。比如前端提交这样一个JSON:
{ "user": { "name": "张三", "age": 25 }, "hobbyList": ["篮球", "读书"] }对应的实体可以设计成:
public class UserRequest { private User user; private List<String> hobbyList; // getter/setter }此时@RequestBody直接把整个JSON反序列化成对象,内部字段对应关系由JSON序列化框架处理,很直接。但如果用的是表单提交,不是JSON体,那么前端input的name就得写成user.name、hobbyList[0]这样的嵌套形式,SpringMVC才能正确绑定到多层结构里。这块很多新手会忽略,导致绑定后内层对象全是null,排查半天发现是字段名不匹配。
另外还有一个经验:参数绑定失败时,框架通常只是抛异常或者填null,不会给你特别明确的提示。所以Controller入口处做参数校验非常必要,可以结合javax.validation的注解给字段加@NotNull、@Size等约束,在方法参数上标注@Valid,框架会自动校验并抛出MethodArgumentNotValidException,再配合全局异常处理器统一返回错误信息,比每个方法里手写判断要优雅得多。
4. JSON交互、文件上传与RESTful设计
4.1 @ResponseBody与HttpMessageConverter
传统JSP项目里,Controller方法返回字符串就是视图名。但前后端分离之后,Controller更多是返回JSON数据,这时候就需要@ResponseBody注解来告诉框架:这个返回值不要走视图解析器,直接序列化成HTTP响应体。
SpringMVC 4开始,方法上标注@RestController,就相当于类上@Controller加方法上@ResponseBody,省了不少注解。
JSON序列化的工作其实是由HttpMessageConverter完成的。默认情况下,如果classpath里存在Jackson库,spring-webmvc会自动注册MappingJackson2HttpMessageConverter。它负责两件事:出参时把Java对象序列化成JSON字符串,入参时把JSON字符串反序列化成Java对象。
我自己的一个经验是,序列化配置值得花心思。比如把日期统一格式化成"yyyy-MM-dd HH:mm:ss":
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder.serializerByType(Date.class, new DateSerializer(false, new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))); } }否则默认序列化的日期是一长串时间戳,前端接入时要多做一层数据处理,不如后端一次给到位。
4.2 文件上传的MultipartResolver配置
文件上传是SpringMVC里一个比较独立的环节。本质上是HTTP协议层通过multipart/form-data格式传输二进制数据,框架要做的是把请求里的文件流封装成MultipartFile对象。
传统XML配置方式:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="10485760"/> <property name="defaultEncoding" value="UTF-8"/> </bean>用配置类方式更简洁:
@Bean public MultipartResolver multipartResolver() { CommonsMultipartResolver resolver = new CommonsMultipartResolver(); resolver.setMaxUploadSize(10 * 1024 * 1024); resolver.setDefaultEncoding("UTF-8"); return resolver; }这里有个非常隐蔽的坑:这个Bean的方法名必须叫multipartResolver。因为DispatcherServlet检测multipart请求时,是按名字去查找这个Bean的,改个名字框架就找不到了,直接当成普通请求处理,导致文件字段为null。
Controller接收文件的方式也很固定:
@PostMapping("/upload") public String upload(@RequestParam("file") MultipartFile file) { // 保存文件 return "success"; }文件保存时,我建议不要直接把原始文件名存到磁盘,一是可能包含路径穿越字符,二是文件名冲突容易覆盖。更稳妥的做法是用UUID生成新文件名,原始文件名存数据库。
4.3 REST风格路由设计
RESTful风格设计接口,核心是用HTTP方法表达操作语义,而不是在URL里用doXXX这样的动词。同样一个资源User,列表查询用GET /users,新增用POST /users,修改用PUT /users/{id},删除用DELETE /users/{id}。
SpringMVC对这套事的支持很直接:
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping public List<User> list() { return userService.listAll(); } @GetMapping("/{id}") public User detail(@PathVariable("id") Long id) { return userService.getById(id); } @PostMapping public User create(@RequestBody User user) { userService.save(user); return user; } @PutMapping("/{id}") public User update(@PathVariable("id") Long id, @RequestBody User user) { user.setId(id); userService.update(user); return user; } @DeleteMapping("/{id}") public void delete(@PathVariable("id") Long id) { userService.deleteById(id); } }设计REST接口时要注意一个状态码问题。Restful语义下,新增成功应该返回201 Created,删除成功返回204 No Content,但你如果直接把方法返回值设成void,默认会返回200。要想精确控制,可以直接用ResponseEntity包一层状态码和响应体,这样客户端的处理逻辑也会更清晰。
5. 拦截器与过滤器补充环节
5.1 HandlerInterceptor的执行时机
拦截器是SpringMVC提供的一种切面机制,但它的执行时机和AOP又不一样。HandlerInterceptor接口定义了三个方法:
- preHandle:Controller方法执行前调用。
- postHandle:Controller方法执行后、视图渲染前调用。
- afterCompletion:整个请求完成后调用,一般用于资源清理。
执行顺序大概是:preHandle返回true,继续走到Controller方法,执行完返回ModelAndView,接着是postHandle,然后视图渲染,最后afterCompletion。如果有多个拦截器,按照注册顺序,preHandle正序执行,postHandle和afterCompletion反序执行。
我早年遇到的一个诡异bug就出在拦截器上:两个拦截器A和B,A的preHandle里写了一行日志,B的postHandle里做了一些统计,但B的preHandle返回了false,导致A的afterCompletion反而执行了。后来才明白,一旦某个拦截器的preHandle返回false,DispatcherServlet会逆序调用所有已执行过preHandle的拦截器的afterCompletion,并且直接中断请求,不会进入Controller。
5.2 与Filter的区别
很多初学者会把Filter和HandlerInterceptor混为一谈,其实两者差距挺大的。
| 维度 | Filter | HandlerInterceptor |
|---|---|---|
| 标准归属 | Servlet规范 | SpringMVC框架 |
| 作用范围 | 所有进入容器的请求 | 被DispatcherServlet分发的请求 |
| 能否访问Spring容器Bean | 需要额外配置 | 可以,本身就是容器管理的 |
| 拦截粒度 | 通用,不能直接定位到某个方法 | 可以直接针对Controller方法 |
| 典型场景 | 编码设置、跨域处理、日志记录 | 登录校验、权限控制、API签名校验 |
我实际项目里的习惯是:Filter做最外层通用处理,比如字符编码、跨域响应头;Interceptor做业务级别控制,比如检查某个接口是否需要登录、当前用户是否有权限访问某个资源。
5.3 登录校验实现样例
拦截器做得最多的场景就是登录校验和权限控制。先实现一个简单的登录拦截器:
public class LoginInterceptor implements HandlerInterceptor { private static final String SESSION_USER = "SESSION_USER"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute(SESSION_USER); if (user == null) { // 判断是不是AJAX请求 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setStatus(401); } else { response.sendRedirect("/login"); } return false; } return true; } }配置注册:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/images/**"); }拦截器逻辑里有一处值得注意:排除静态资源路径。如果忘了排除css和js,页面会加载不出来,因为样式和脚本请求也被拦截重定向到登录页了。排查这个问题的时候,如果你看浏览器Network里的状态码是302,基本就是拦截器误伤。
权限控制的思路也类似,在preHandle里检查当前用户是否有访问当前URL所需角色,没有就直接返回403。这里建议把权限数据缓存起来,不要每次都查数据库,否则系统压力会大。
6. 高频问题排查与避坑清单
6.1 404与请求映射问题
请求打到SpringMVC上却返回404,是我见过最多的问题。排查思路总结下来可以按顺序过一遍:
- Controller类有没有被扫描到。确认@ComponentScan的basePackage覆盖了Controller所在包。
- 类上有@Controller注解,且不是@RestController混用导致的包扫描不到。
- 方法上有@RequestMapping或@GetMapping等映射注解。
- web.xml里DispatcherServlet的url-pattern配置是否正确,是不是被/*抢占了。
- 请求方式对不对。方法上是@GetMapping但你用POST请求,同样会报405。
这里有个细节:SpringMVC对没有匹配到的Handler,最终会抛出NoHandlerFoundException,但这个异常如果没配置异常处理器,默认可能直接404。你可以在配置类里开启静态资源处理和404异常捕获,把未匹配请求统一返回JSON结构,比白屏页面好排查得多。
6.2 中文乱码与编码设置
乱码问题说到底是字符编码不一致。一个请求从浏览器到服务器,经过Tomcat解码、Spring参数解析、数据库存储、响应编码几个环节,任何一环编码不对都会乱。
传统Tomcat配置下,POST请求的乱码通常要设置CharacterEncodingFilter:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>forceEncoding设为true很关键,它会强制request和response都使用指定的编码,否则如果请求头里没有charset,可能还是按默认ISO-8859-1处理。
响应JSON乱码的问题,可以用produces属性指定编码:
@GetMapping(value = "/info", produces = "application/json;charset=UTF-8") public String info() { return "{\"name\":\"张三\"}"; }如果返回的是对象由Jackson序列化,通常没问题,但如果你手写了String返回,这招很管用。
6.3 跨域问题
前后端分离项目里,跨域请求基本躲不掉。SpringMVC处理跨域有几种方式,最简单的是在Controller类上加@CrossOrigin注解:
@CrossOrigin(origins = "http://localhost:8081") @RestController @RequestMapping("/api") public class ApiController { }更全局的做法是在WebMvcConfigurer里配置跨域规则:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8081") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }有个细节需要注意:浏览器发送非简单请求时,会先发一个OPTIONS预检请求,这个请求不带业务参数,如果你的过滤器或拦截器没把OPTIONS请求放行,跨域请求会卡在预检阶段,表现为页面报跨域错误但Network里看不到业务请求,只看到OPTIONS返回非2xx。
6.4 参数绑定失败调试手段
参数绑定不上,是另一个高频问题。出现这种情况时,我建议第一时间打开框架的日志级别,把org.springframework.web调到DEBUG,看参数解析过程具体在哪一步断了。
常见的原因有这些:
- 参数名对不上。Java编译后没有保留参数名信息时,SpringMVC靠反射拿到的方法参数名可能变成arg0,这时如果没用@RequestParam指定value,绑定就会失败。
- 类型转换失败。前端传了一个"abc"到Integer字段,转换异常被抛出。
- 嵌套对象的字段名层级不对。表单字段没有按user.name这种格式提交。
- JSON结构不对。@RequestBody接收时,前端传的JSON与实体字段对应不上,多数字段被赋了默认值。
类型转换失败的场景,我建议看看HandlerMethodArgumentResolver这一层的异常,在全局异常处理器里捕获BindException和MethodArgumentNotValidException,统一返回带字段信息的错误提示:
@ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> handleValidationException(MethodArgumentNotValidException e) { Map<String, String> errors = new HashMap<>(); e.getBindingResult().getFieldErrors().forEach(err -> errors.put(err.getField(), err.getDefaultMessage())); return ResponseEntity.badRequest().body(errors); }这样一来,前端可以直接把errors里每个字段的错误信息展示在表单对应位置,体验好很多。
结尾
写到这里,SpringMVC的核心链路基本覆盖完了:从架构分层到配置装配,从参数绑定到JSON交互,从拦截器设计到问题排查,每一个环节都值得亲手敲一遍。我在实际项目中最大的感受是,SpringMVC的体系设计得很规整,它把很多复杂的事情封装在框架内部,给了开发者清晰的扩展点,但这也意味着你不能只会用注解,还得理解它背后那套责任链式的处理机制。遇到问题的时候,别急着瞎猜,打开DEBUG日志跟着请求走一趟,往往比看十篇博客都管用。最后再分享一个小技巧:如果你在某个接口上需要同时处理JSON和表单参数,不要把两种参数混在一个方法里,优先拆分接口,各自职责单一,后面扩展和维护都会轻松很多。