2026/10/12 15:40:35

SpringMVC核心原理与实战:一次请求从URL到Controller的完整链路

SpringMVC核心原理与实战:一次请求从URL到Controller的完整链路 接手过不少老项目也带过新人每次讲到SpringMVC我都会先问一个问题在没有Spring Boot的年代一个请求从浏览器出发到底是怎么找到Controller里的那个方法的能把这个链路讲清楚的人SpringMVC基本就吃透了。这也是为什么我今天想专门写一篇SpringMVC介绍——不管你现在是直接用Spring Boot开发还是维护老项目SpringMVC这套模型始终是Java Web开发的地基搞懂它你排查问题、设计接口、写拦截器都会有底气得多。SpringMVC本身不是一个很难的技术但它涉及的概念非常多DispatcherServlet、HandlerMapping、HandlerAdapter、ViewResolver、Interceptor……新手最容易被这一堆名词劝退。这篇文章我不打算按官方文档的目录给你念一遍而是从一个请求的完整生命周期讲起把核心组件、Controller开发、拦截器机制、高频坑点一条线串下来。适合正准备系统学SpringMVC的人也适合已经用Spring Boot写了一阵子、想回头补地基的人。1. SpringMVC核心工作流程拆解1.1 从请求到响应DispatcherServlet的绝对控制权SpringMVC的设计核心是一个叫DispatcherServlet的前端控制器你可以把它想象成公司前台所有外部请求先到它这里它不做实际业务但负责把请求分发给正确的部门等部门干完活再统一收口返回。这种模式叫前端控制器模式好处是整个请求链路有了唯一的入口过滤、拦截、分发、异常处理都能集中管控。一个典型的SpringMVC请求处理链路是这样的浏览器发起HTTP请求到达Servlet容器Tomcat等被分配给DispatcherServlet。DispatcherServlet通过HandlerMapping查找能处理该请求的Handler也就是Controller方法返回一条执行链HandlerExecutionChain包含处理器本身和一组拦截器。DispatcherServlet拿到Handler后通过HandlerAdapter来实际调用处理器方法。为什么要多这一层适配因为SpringMVC支持的处理器类型不止注解版Controller还有实现Controller接口、HttpRequestHandler等方式适配器模式让这些不同的处理器能统一被执行。Controller方法执行业务逻辑返回一个ModelAndView模型数据加视图名或者直接返回数据如JSON。DispatcherServlet把视图名交给ViewResolver解析成真正的View对象View负责渲染模型数据最终生成响应内容返回给浏览器。这套流程里DispatcherServlet始终掌握节奏。很多初学者会疑惑我写Controller时根本没碰过ModelAndView为什么方法返回一个字符串也能正常渲染因为SpringMVC内部做了大量的默认处理后续我会细讲。1.2 核心组件职责与协作关系如果只记一组概念我建议你优先记住这四个HandlerMapping、HandlerAdapter、ViewResolver、HandlerExceptionResolver。HandlerMapping的职责是建立“请求URL到Handler”的映射关系。最常用的是RequestMappingHandlerMapping它负责解析Controller类上和方法上的RequestMapping注解把URL、HTTP方法、参数条件等封装成映射条件。项目启动时Spring容器会把这些映射关系注册好请求进来时直接按条件匹配。HandlerAdapter负责真正调用Handler。RequestMappingHandlerAdapter是最常用的一种它做了这些事解析方法参数把HTTP请求里的参数绑定到方法入参、调用方法、处理返回值包装成ModelAndView或直接写回响应。参数解析器HandlerMethodArgumentResolver和返回值处理器HandlerMethodReturnValueHandler是它手里的两套工具链后面Controller部分我会具体展开。ViewResolver负责把逻辑视图名转为物理视图。比如Controller返回字符串user/listInternalResourceViewResolver配置了前缀/WEB-INF/和后缀.jsp那最终视图路径就是/WEB-INF/user/list.jsp。如果你的接口是ResponseBody/ RestController返回JSON那这一步就绕过了视图解析直接由消息转换器HttpMessageConverter把对象序列化成JSON写回响应这就是为什么很多RESTful项目里压根没有配视图解析器也能跑得好好的。HandlerExceptionResolver是异常处理中枢。SpringMVC里ExceptionHandler、ControllerAdvice等注解的底层就是它。请求处理过程中任何一环抛异常最终都会汇聚到这里统一处理把异常转换成响应结果。1.3 组件配置方式演变XML到Java Config早期版本配置这些组件需要在spring-mvc.xml里写一堆Bean定义比如mvc:annotation-driven / bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views/ / property namesuffix value.jsp / /bean那段时期HandlerMapping、HandlerAdapter基本靠mvc:annotation-driven /这个标签自动注册。如果你去翻老项目经常能看到这个配置新人往往不知道它到底干了什么其实就是帮你在容器里注册了RequestMappingHandlerMapping、RequestMappingHandlerAdapter、ExceptionHandlerExceptionResolver等一堆默认组件。到了Spring 3.1之后Java Config方式逐渐成为主流Spring Boot更彻底——spring-boot-starter-web通过自动配置类WebMvcAutoConfiguration把这些组件全部按默认策略装配好。你写一个Controller方法加个注解就能启动跑起来感觉像魔法。但请记住不是没有这些组件了而是框架帮你默默做了。理解这一点你才能真正自如地扩展SpringMVC比如自定义参数解析器、自定义消息转换器。2. Controller开发实战详解2.1 Controller与RestController别选错了Controller是SpringMVC最基础的控制器注解标注了它的类会被HandlerMapping扫描到类内部带有RequestMapping的方法会被识别为处理器方法。但它有一个特点默认方法返回值会被当作视图名处理。举个经典例子Controller public class UserController { RequestMapping(/user/list) public String list(Model model) { ListUser users userService.findAll(); model.addAttribute(users, users); return user/list; // 交给ViewResolver解析为视图 } }这段代码做的事情是把users塞进模型然后返回视图名user/list最终由InternalResourceViewResolver解析成JSP模板渲染成HTML页面。这是传统的服务端渲染模式。而RestController是Spring 4.0引入的组合注解本质上是Controller加ResponseBody。它内部所有方法的返回值都会直接通过HttpMessageConverter序列化后写入HTTP响应体不再走视图解析。日常开发中如果前后端分离接口返回JSON直接用RestController就对了RestController public class UserApi { GetMapping(/api/user/{id}) public User detail(PathVariable Long id) { return userService.getById(id); } }这里有个值得注意的点方法返回的User对象没有任何序列化注解SpringMVC怎么知道要转成JSON关键在于HandlerAdapter的返回值处理器链里有一个RequestResponseBodyMethodProcessor它会检测方法是否标注了ResponseBody如果是就从Spring容器里找能处理User类型的HttpMessageConverter。Spring Boot的WebMvcAutoConfiguration默认会注册MappingJackson2HttpMessageConverter基于Jackson所以对象就被序列化成JSON了。这也解释了为什么如果你的项目引入了Jackson的依赖却返回不了JSON或者返回JSON时报出“No converter found”之类的错大概率是消息转换器没注册成功。2.2 参数绑定五种主流接收方式Controller方法参数绑定是日常开发最频繁碰到的内容我把常用的方式整理如下RequestParam绑定单个请求参数适用于GET的query string或表单提交的application/x-www-form-urlencoded。比如RequestParam(page) int page获取?page1。PathVariable绑定URL路径变量适用于RESTful风格接口。比如GetMapping(/user/{id})里的PathVariable Long id。RequestBody绑定请求体常用于POST、PUT接口接收JSON数据。框架会通过HttpMessageConverter把请求体反序列化为对象。ModelAttribute绑定表单对象把多个请求参数绑定到一个Java对象的字段上。其实SpringMVC在处理没有注解的普通对象参数时也会自动做类似的对象绑定按参数名匹配对象属性。HttpServletRequest/HttpServletResponse/HttpSession需要直接操作原生Servlet API时传入。举一个综合点的例子RestController public class OrderApi { PostMapping(/order/{orderNo}) public Result submit(PathVariable String orderNo, RequestParam(required false) String remark, RequestBody OrderDTO dto, HttpServletRequest request) { // orderNo来自URLremark来自query stringdto来自请求体JSON String operator (String) request.getAttribute(operator); return orderService.submit(orderNo, remark, dto, operator); } }这里补充几个容易踩坑的细节。第一RequestParam标注的参数默认是必需的没传会报400错误并提示MissingServletRequestParameterException如果你希望可选要显式设置required false。第二PathVariable的参数名和方法参数名如果不一致需要显式指定名称比如PathVariable(order_no) String orderNo。第三RequestBody接收JSON时如果Content-Type不是application/json转换器根本不会被触发请求体会停留在未解析状态此时参数可能为null常常出现在手动用HTTP客户端调试时忘了设置Header的场景。2.3 数据校验与响应包装接口开发中肯定要处理参数校验和统一响应结构。SpringMVC的校验是通过JSR-303/JSR-380规范的校验注解实现的配合Valid或Validated注解触发。RestController public class UserApi { PostMapping(/api/user) public Result create(Validated RequestBody UserCreateDTO dto) { // 校验失败会抛出MethodArgumentNotValidException userService.create(dto); return Result.success(); } } Data public class UserCreateDTO { NotBlank(message 用户名不能为空) Email(message 邮箱格式不正确) private String email; Size(min 6, max 20, message 密码长度必须是6到20位) private String password; }校验失败时会抛MethodArgumentNotValidException默认情况下SpringMVC会返回400和一个包含错误信息的错误响应体但结构不统一。所以我建议用RestControllerAdvice做全局异常处理把校验异常统一包装成自己的Result结构前端对接起来才省心。这是开发阶段就应该定好的接口规范而不是等项目上线再补。3. 拦截器机制从登录校验到接口日志3.1 HandlerInterceptor的三个生命周期方法SpringMVC的拦截器Interceptor是HandlerExecutionChain上的一环它的作用范围是“处理器方法执行前后”。实现一个拦截器只需要实现HandlerInterceptor接口最常见的就是重写三个方法preHandle处理器方法执行前调用。返回true则继续执行后续拦截器和处理器返回false则中断且不再执行处理器方法及后续拦截器。postHandle处理器方法执行后、视图渲染前调用。适合对ModelAndView做补充处理但在RestController场景中基本用不上因为响应体已经写完了。afterCompletion整个请求完成之后调用适合做资源清理、记录耗时等。下面是一个实际可用的登录校验拦截器示例public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求和登录接口 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } if (request.getRequestURI().contains(/login)) { return true; } Object userId request.getSession().getAttribute(userId); if (userId null) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } // 把用户信息塞进request后续Controller直接用 request.setAttribute(currentUserId, userId); return true; } }为什么很多团队愿意用拦截器做登录校验因为登录校验是横跨所有Controller的公共逻辑上到鉴权、下到日志如果写在每个接口里那是灾难。拦截器把公共逻辑从业务代码中抽离出来职责单一符合AOP思想。不过要提醒一点preHandle里返回false之后后续的postHandle和afterCompletion都不会执行所以如果你在afterCompletion里做日志记录注意请求被中断时也会少一条日志需要根据业务容忍度决定是否在preHandle失败时自行记录。3.2 拦截器注册与执行顺序写好了拦截器还需要注册才能生效。在Spring Boot中通过实现WebMvcConfigurer接口来注册Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/register); registry.addInterceptor(new AccessLogInterceptor()) .addPathPatterns(/**); } }addPathPatterns用来配置拦截路径excludePathPatterns用来排除路径。这里我特别说明一下排除路径的优先级高于拦截路径且SpringMVC支持Ant风格通配符匹配比如/api/**匹配/api下的所有子路径。关于执行顺序注册的先后顺序就是preHandle的执行顺序。假设先注册LoginInterceptor再注册AccessLogInterceptor那么请求处理顺序是LoginInterceptor.preHandleAccessLogInterceptor.preHandleController方法执行AccessLogInterceptor.postHandleLoginInterceptor.postHandleAccessLogInterceptor.afterCompletionLoginInterceptor.afterCompletionpostHandle和afterCompletion的执行顺序是注册顺序的反序。这个机制和拦截器链的设计有关有点类似于栈式调用——越早进入的越晚退出。如果你在两个拦截器之间有依赖关系比如日志拦截器想记录包含用户信息的操作记录那必须确保登录拦截器先执行因此注册顺序不能随意调换。3.3 拦截器与Filter很多人其实没分清这是一个面试高频问题也经常导致线上事故。Filter和Interceptor看着都能在请求前后做点事但本质不同对比项FilterHandlerInterceptor规范起源Servlet规范SpringMVC框架作用范围所有Web资源Servlet、静态资源、Controller仅SpringMVC处理的处理器方法是否能获取Spring Bean不能直接获取通常通过委托或WebApplicationContextUtils获取本身可以注入Spring Bean是否依赖Spring容器启动不依赖Web容器初始化时创建依赖Spring容器初始化时注册典型场景字符编码、跨域、日志、压缩登录校验、权限控制、接口耗时最常见的误用是把字符编码设置放在拦截器中。其实一旦请求体被读取再修改编码就来不及了所以在Filter阶段就要把request的编码设置好。Spring Boot中直接使用CharacterEncodingFilter就是干这个的它甚至会在Servlet容器里优先注册。生产方式上Filter通过WebFilter或FilterRegistrationBean注册拦截器通过WebMvcConfigurer注册两者不在同一条链上Filter优先于DispatcherServlet、也优先于Interceptor。简单记Filter管的事更底层更前置Interceptor管的事更贴近业务。4. 高频问题排查与配置优化经验4.1 中文乱码的根因与解决方案中文乱码问题几乎每个搞Web开发的人都遇到过。它的根源一句话讲清楚请求体或响应体在字节与字符转换时使用的字符集和浏览器约定的不一致。如果是POST表单提交或GET参数乱码检查点有三处。第一页面提交时是否指定了charset常见HTML表单默认使用ISO-8859-1需要设置accept-charset或确保页面本身是UTF-8第二Servlet容器读取请求参数的编码Tomcat 8以上GET请求URI默认UTF-8但表单POST默认是ISO-8859-1需要设置请求编码第三数据库连接层、JSP页面是否都统一UTF-8。在Spring Boot中最稳妥的做法是显式配置强制UTF-8spring.http.encoding.charsetUTF-8 spring.http.encoding.enabledtrue spring.http.encoding.forcetrueforcetrue的作用是无论请求还是响应都强制使用指定编码。这一点在接口返回JSON时尤为重要否则响应头里的Content-Type可能是text/html;charsetISO-8859-1前端解析出来就是乱码。我自己排查乱码的顺序一直是浏览器Network面板看响应头Content-Type - 看Controller返回值是否有ResponseBody - 看全局过滤器是否设置编码 - 看序列化配置是否指定了字符集。按这个顺序基本能定位。4.2 接口404的排查路径接口明明写了浏览器访问却404。我在带新人时建议按这个顺序排查访问的URL是否和RequestMapping完全匹配。注意大小写、路径变量、参数占位符。是否少了RestController或ResponseBody。如果类没被Spring扫描到HandlerMapping里根本没有这个Handler。Controller类是否在Spring Boot启动类的子包下。Spring Boot的组件扫描默认只扫描启动类所在包及其子包如果你的类放在外包我见过太多次这种低级错误。解决办法是主动加ComponentScan指定包但我建议干脆按规范放好。项目是否有拦截器拦截了请求或路径匹配规则冲突了。比如你注册个拦截器拦截了/api/**又排除错路径处理器根本到不了。Spring MVC的静态资源处理是否和你的接口冲突。如果你定义了GetMapping(/css/style.css)Spring Boot的默认资源映射也会尝试可能触发冲突。排查最有效的手段永远是看日志。Spring Boot启动时会有类似Mapped to [/api/user/{id}]的日志如果你能搜到这条说明HandlerMapping注册成功问题多半出在请求路径或拦截器上如果搜不到说明Handler都没注册进去重点查扫描路径。4.3 RequestBody传输报错消息转换器未生效RequestBody接收JSON时报错常见的错误信息是HttpMediaTypeNotSupportedException: Content type application/json;charsetUTF-8 not supported或者解析后对象全是null。第一种情况基本是缺少MappingJackson2HttpMessageConverter。Spring Boot引入spring-boot-starter-web时会自动带上Jackson但如果你手动搭建SSM环境没有引入jackson-databind依赖消息转换器就注册不进来。解决办法是确认Maven/Gradle里有dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency第二种情况是Content-Type没设置对。有的测试工具默认Content-Type是application/x-www-form-urlencoded那请求体解析时就不会走JSON转换器。这个不算代码问题但排查起来容易怀疑人生我的经验是调试接口第一件事看请求头。第三种情况是前端传的JSON字段和DTO字段对不上。比如数字类型传成了字符串、日期格式不匹配、字段名大小写不一致反序列化可能失败或得到null。建议在DTO字段上用JsonProperty明确映射关系必要时配置Jackson的LocalDateTime序列化格式。4.4 静态资源被拦截器拦截的坑Spring Boot默认的静态资源映射是/如果你注册了拦截器并配置了addPathPatterns(/)那静态资源也会被拦截。如果preHandle里写了访问Session或做登录校验很容易出现页面样式全部丢失、图片加载失败的问题。解决思路有两种。第一种是拦截路径不要用/而是精确一点比如/api/、/admin/**这类业务路径把静态资源排除在外。第二种是保留拦截路径但在排除规则里加上静态资源目录registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/css/**, /js/**, /images/**, /error);这里还有一个更隐蔽的细节页面渲染时除了直接请求静态资源还可能发生内部转发到错误页像Spring Boot的默认错误路径/error也可能被拦截器拦到。如果在preHandle里做用户校验碰上“未登录访问错误页”这种场景容易造成逻辑死循环或拦截器响应码异常我习惯把/error也加进排除路径。5. 关于SpringMVC我最后想说的几句技术文章写到这里道理都讲得差不多了但我还是想分享几点切身感受。我见过太多人从Spring Boot开始学Web开发控制器、接口、注解一套一套用得很溜可一旦遇到诡异问题就完全无从下手根因就是对SpringMVC这套底层流转缺乏感知。框架确实帮我们省掉了很多样板代码但也正因为省掉了出问题时的排查线索也被遮蔽了。我的建议是如果你真想把这套东西吃透手动搭建一次SSM环境哪怕只是个小Demo也行。写一个web.xml、配置DispatcherServlet、配置HandlerMapping、配一个视图解析器亲手把Controller跑起来。这个过程会让你对那些“自动配置”的组件形成肌肉记忆以后在任何Spring生态项目里折腾都会更从容。另外Spring Boot 3.x里SpringMVC已经基于Jakarta EE 9网上很多老教程里的javax包名全部要换成jakarta初学者照着老代码抄容易编译失败这个变化值得留心。最后分享一个排查技巧SpringMVC的几乎所有组件都是可替换、可扩展的当你觉得某个行为不对时先不要急着加“奇怪配置”试着在应用启动时输出所有HandlerMapping、HandlerAdapter、HttpMessageConverter的注册情况看看框架实际给你装配了哪些东西。很多时候问题一眼就清楚了。