
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。根据请求的URLDispatcherServlet询问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 groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.32/version /dependency然后在web.xml里配置DispatcherServletservlet 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这里有一个我早期经常踩的坑url-pattern用/*还是/效果完全不同。/*会拦截所有请求包括JSP和静态图片这会导致JSP页面无法正常渲染显示成源码或者静态资源404。/是默认Servlet路径DispatcherServlet接管除了JSP以外的请求。所以项目里的JSP尽量放到/WEB-INF/目录下面直接由容器处理业务请求都走DispatcherServlet。2.2 注解驱动与配置类方式spring-mvc.xml里的核心配置也就是开启SpringMVC注解支持的部分context:component-scan base-packagecom.example.controller/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /beancomponent-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} 取idRequestParam从查询参数或表单取值?pageNum1 取pageNumRequestBody从请求体中反序列化JSON接收整个对象ModelAttribute绑定表单字段到实体对象表单提交用户数据RequestHeader从请求头取值取AuthorizationCookieValue从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 ListString 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:ssConfiguration 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 idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value10485760/ property namedefaultEncoding valueUTF-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 ListUser 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接口定义了三个方法preHandleController方法执行前调用。postHandleController方法执行后、视图渲染前调用。afterCompletion整个请求完成后调用一般用于资源清理。执行顺序大概是preHandle返回true继续走到Controller方法执行完返回ModelAndView接着是postHandle然后视图渲染最后afterCompletion。如果有多个拦截器按照注册顺序preHandle正序执行postHandle和afterCompletion反序执行。我早年遇到的一个诡异bug就出在拦截器上两个拦截器A和BA的preHandle里写了一行日志B的postHandle里做了一些统计但B的preHandle返回了false导致A的afterCompletion反而执行了。后来才明白一旦某个拦截器的preHandle返回falseDispatcherServlet会逆序调用所有已执行过preHandle的拦截器的afterCompletion并且直接中断请求不会进入Controller。5.2 与Filter的区别很多初学者会把Filter和HandlerInterceptor混为一谈其实两者差距挺大的。维度FilterHandlerInterceptor标准归属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请求的乱码通常要设置CharacterEncodingFilterfilter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncoding设为true很关键它会强制request和response都使用指定的编码否则如果请求头里没有charset可能还是按默认ISO-8859-1处理。响应JSON乱码的问题可以用produces属性指定编码GetMapping(value /info, produces application/json;charsetUTF-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 ResponseEntityMapString, String handleValidationException(MethodArgumentNotValidException e) { MapString, 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和表单参数不要把两种参数混在一个方法里优先拆分接口各自职责单一后面扩展和维护都会轻松很多。