1. 从Servlet到SpringMVC:一个前端控制器解决的核心痛点
如果你写过几年Java Web,多半经历过早期Servlet开发的那个阶段:一个功能一个Servlet类,doGet、doPost里塞满了一堆request.getParameter,然后手动setAttribute、forward到JSP。业务稍微多起来,十几个Servlet互相跳转,代码像蜘蛛网一样缠在一起。后来接触SpringMVC,突然发现原来写接口可以这么干净——一个注解搞定URL映射、参数自动绑定、返回值自动序列化。但用了很久心里始终有个模糊地带:它到底是怎么把请求送进Controller方法的?@RequestMapping背后又是怎么把一串字符对应到一个Java方法的?这篇文章就把这块补上。
SpringMVC的核心价值可以概括为三句话:用DispatcherServlet做统一入口,用HandlerMapping做URL寻址,用HandlerAdapter做方法调用。它把Servlet开发中最琐碎、最重复的那部分工作——参数获取、类型转换、请求分发、视图跳转——全部收编成可配置的组件,而开发者在Controller里只需要关心业务本身。适合刚学完SSH或传统Servlet、想搞懂SpringMVC本质的读者,也适合用过SpringBoot但没系统梳理过SpringMVC底层的老开发。
2. 一次请求的完整旅程:DispatcherServlet与四大组件的协作
2.1 从前端控制器开始的请求分发
先说一个基础概念:DispatcherServlet本质就是一个Servlet,它继承自HttpServlet,但它不是你写的那个Servlet,而是Spring框架替所有Controller统一挡箭的入口。所有请求先到它手里,再由它转交给对应的Controller方法。这种设计模式叫前端控制器模式(Front Controller),它解决的核心问题是:让所有请求都经过同一个处理点,把"接收请求-分发请求"和"执行业务逻辑"彻底解耦。
一个完整的HTTP请求进来之后,DispatcherServlet大致按下面这个顺序干活:
- 收到请求后,先通过HandlerMapping找到处理这个URL的Handler(也就是Controller方法),返回一条执行链HandlerExecutionChain。
- 拿到执行链上的Handler之后,用HandlerAdapter去真正调用它。这里为什么要多一层Adapter?因为SpringMVC支持的Handler类型不单单是@Controller方法,还有可能是HttpRequestHandler、Servlet等老式处理器,Adapter模式把这些不同类型的处理器统一成同一个调用入口。
- Controller方法执行完,返回ModelAndView(如果使用@ResponseBody则直接写回JSON)。
- 如果有视图需要渲染,ViewResolver将逻辑视图名解析成真正的View对象,然后在Response中输出。
2.2 HandlerMapping与HandlerAdapter之间的配合逻辑
很多人觉得HandlerMapping和HandlerAdapter是一个东西,其实不是。我打个比方:HandlerMapping是查号台,你报一个URL(比如/order/detail),它告诉你"这个号码属于张三";HandlerAdapter是翻译官,哪怕对方只会讲外语,它也能给你翻译成听得懂的话,去调用张三的方法。
SpringMVC里最常见的HandlerMapping实现是RequestMappingHandlerMapping,它会扫描所有标注了@Controller的类,把URL和方法的信息收集起来,建立一张映射表。请求来了就按这张表做匹配,不仅匹配路径,还会匹配请求方法(GET/POST)、参数条件等。而RequestMappingHandlerAdapter则负责真正执行Controller方法,它要做的事情包括:解析方法参数、执行类型转换、处理@RequestBody、调用方法、处理返回值。这两者一查一调,分工明确。
这里有个容易忽略的点:DispatcherServlet拿到HandlerExecutionChain后,会先执行链上注册的HandlerInterceptor的preHandle方法,执行完毕才进入Controller方法调用;返回阶段再执行postHandle和afterCompletion。所以拦截器逻辑和业务逻辑是同一个执行链上的不同节点,这也是为什么SpringMVC拦截器能对请求做前置校验、日志记录、登录鉴权。
2.3 一个完整示例带你串起整条链路
为了让你不晕,用一个最简单的例子走一遍。假设你在Controller里写了这样一段代码:
@Controller public class OrderController { @GetMapping("/order/detail") @ResponseBody public String detail(@RequestParam("id") Long orderId) { return "order:" + orderId; } }浏览器访问/order/detail?id=1001时,实际上发生了这些事:
- Tomcat把请求交给DispatcherServlet(servlet映射路径为/)。
- DispatcherServlet调用RequestMappingHandlerMapping,遍历所有已注册的映射条件,找到匹配
/order/detail且请求方法是GET的HandlerMethod。 - 校验链上的拦截器,本示例没有拦截器,直接跳过。
- RequestMappingHandlerAdapter接手,解析
@RequestParam("id") Long orderId这个参数,从request中取出名为id的字符串值"1001",通过类型转换器转成Long类型的1001,然后调用detail方法。 - 方法返回字符串"order:1001",由于方法上有@ResponseBody,返回内容直接通过HttpMessageConverter写入响应体,不经过视图解析。
整个流程清晰、表达简洁,这就是SpringMVC带来的开发范式变化。后面所有深入的内容,都是围绕这四个组件的某个环节展开的。
3. 容器初始化演进:从web.xml配置到无配置启动
3.1 传统SSM时代的web.xml配置
在SpringBoot普及之前,一个标准的SpringMVC项目必须在web.xml里配置两件事:一是ContextLoaderListener,用来初始化Spring根容器;二是DispatcherServlet,用来初始化SpringMVC自己的WebApplicationContext。配置大致长这样:
<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <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/springmvc.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>这里有个非常重要的知识点:SpringMVC存在两个Spring容器,一个是ContextLoaderListener创建的Root WebApplicationContext(根容器),一个是DispatcherServlet自己创建的Servlet WebApplicationContext(子容器)。子容器可以访问父容器的Bean,父容器访问不到子容器的Bean。如果将Controller配置在根容器里,同时SpringMVC上下文里又开启了对Controller的扫描,就可能出现同一个Bean被实例化两份的问题。
很多初学者在这个地方栽过跟头。比如把<context:component-scan base-package="com.example" />同时写在applicationContext.xml和springmvc.xml里,Service和Controller被扫了两次,事务AOP代理在某些情况下会失效。传统SSM开发中约定俗成的做法是:springmvc.xml只扫Controller,applicationContext.xml扫除了Controller之外的所有组件。这个约定基于的正是父子容器的边界逻辑。
3.2 Servlet3.0+与WebApplicationInitializer
到了Servlet3.0时代,web.xml不再是唯一选择,容器启动时会自动查找实现了WebApplicationInitializer接口的类,Spring提供了它的抽象实现AbstractAnnotationConfigDispatcherServletInitializer,用纯Java代码完成同样的初始化:
public class WebInitializer 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[]{"/"}; } }代码配置相比XML,最大的优势是可以利用编译时类型检查,而且配置逻辑和业务代码放在同一个工程体系里,重构友好度大幅提升。不过它对应的还是双容器模型:getRootConfigClasses对应根容器,getServletConfigClasses对应子容器。
3.3 SpringBoot下的自动配置改变
SpringBoot出现后,双容器的概念被冲淡了很多。SpringBoot的DispatcherServletAutoConfiguration自动注册了DispatcherServlet,默认映射路径还是/,同时WebMvcAutoConfiguration帮我们自动配置了RequestMappingHandlerMapping、RequestMappingHandlerAdapter、ViewResolver等一堆组件。除非你手动定义WebMvcConfigurer,否则大部分配置都不用关心。
但注意一个细节:SpringBoot默认的组件扫描只会扫描主启动类所在包及其子包。很多人把Controller放在主类包外面,结果配了一堆注解就是不生效,最后发现是扫描路径的问题。这不是SpringMVC本身变了,而是自动配置时代把初始化逻辑藏在了背后,反而让"扫描包范围"这个基础知识变得更加重要。想要进得了容器,先得让组件被扫到;想要被扫到,就必须待在扫描路径覆盖的区域内。
4. @RequestMapping核心属性拆解:不止是路径映射
4.1 value/path:路径映射的老大
@RequestMapping最基础的属性就是value(别名path)。它可以标注在类上,也可以标注在方法上。类上标注表示整个Controller共享的路径前缀,方法上标注才是接口的具体路径。实际请求的URL是两者拼接的产物。
@Controller @RequestMapping("/user") public class UserController { @RequestMapping("/list") @ResponseBody public String list() { return "user list"; } }上面代码中,访问路径就是/user/list。这里有两个容易忽略的写法细节:
路径值可以写数组,比如@RequestMapping({"/list", "/query"}),让两个路径映射到同一个方法;也可以使用占位符方式@RequestMapping("/user/{id}")在路径中传递参数,这就是RESTful风格里获取URL路径参数的入口。有些团队习惯把路径集中在类上写,方法里只写相对路径,这种坐法本身没问题,但要注意整个项目的代码风格保持统一,否则后续维护时找人很费劲。
4.2 method:限制请求类型的正确姿势
method属性用来限定HTTP方法类型,取值是RequestMethod枚举:GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS、TRACE。当不指定method时,意味着所有方法类型都会接受;一旦指定,不符合条件的请求会被SpringMVC拒绝并返回405。
@RequestMapping(value = "/order/add", method = RequestMethod.POST) public String add() { // 仅处理POST请求 }在RESTful接口设计中,method是核心约束。同一个URL(比如/order/1001),GET代表查询、DELETE代表删除、PUT代表整体更新、PATCH代表部分更新,完全依靠method来区分语义。如果你在方法上不写method,等于这个地址同时暴露了四种操作入口,这在权限校验严格的项目里是个隐患。SpringMVC后续推出的GetMapping、PostMapping等衍生注解,本质上就是提前绑定了method属性的小包装。
4.3 params:按请求参数条件过滤
params属性允许你按照请求中是否包含某个参数、参数值是多少来过滤请求命中。这个属性用得非常少,但偶尔能解决比较刁钻的需求。比如:
@RequestMapping(value = "/order/query", params = "type=now") @ResponseBody public String queryNow() { return "只处理带type=now的请求"; } @RequestMapping(value = "/order/query", params = "type=history") @ResponseBody public String queryHistory() { return "只处理带type=history的请求"; }同一个URL,根据参数值不同走到不同的方法,这在做条件分发时很有用。不过实际项目中这样用容易把接口搞乱,我个人的建议是:除非是兼容历史接口这样的特殊需求,否则尽量少用params做业务分发,让逻辑在方法内部推进,而不是拆散到多个方法入口上。
4.4 headers:基于请求头做条件限制
headers的效果和params类似,只不过匹配对象从参数变成了HTTP头部。例如要求必须带有某个自定义头:
@RequestMapping(value = "/api/data", headers = "X-Platform=android") @ResponseBody public String androidData() { return "android data"; }常用于同一接口按端分流,或者做灰度发布时的简易校验。同样地,headers与SpringMVC提供的@RequestHeader是不同的东西:headers属性是请求是否能进入方法的筛选条件,@RequestHeader是把请求头的值注入方法参数。
4.5 consumes与produces:内容协商的两种约束
consumes表示处理请求的Content-Type,produces表示返回的Content-Type。看例子:
@PostMapping(value = "/user/save", consumes = "application/json") @ResponseBody public User save(@RequestBody User user) { return userService.save(user); }普通表单请求的Content-Type通常是application/x-www-form-urlencoded或multipart/form-data,如果你用consumes限定为application/json,前端就必须用JSON格式提交,否则SpringMVC返回415 Unsupported Media Type。produces的作用类似,它会将响应内容的Content-Type设置成指定值,比如:
@GetMapping(value = "/user/export", produces = "application/vnd.ms-excel")这行配置告诉浏览器我返回的是Excel文件,前端拿到响应后能根据Content-Type做出正确响应。内容协商(Content Negotiation)是SpringMVC比较底层的设计,这两个属性就是它对外暴露的简单开关。
5. 衍生注解与RESTful风格:@GetMapping是如何取代@RequestMapping的
5.1 四个常用衍生注解的本质
SpringMVC在4.3版本引入了@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping。它们的本质都是@RequestMapping的组合注解。以@GetMapping为例:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @RequestMapping(method = RequestMethod.GET) public @interface GetMapping { // value、path、params、headers、consumes、produces 重复声明 }所以@GetMapping("/user")等价于@RequestMapping(value = "/user", method = RequestMethod.GET)。但它比后者多了两个优势:代码更短、语义更明确;类上想加前缀时,可以非常直观地用组合注解组织方法结构。
我在实际项目里建议的方法:类上用@RequestMapping("/user")做前缀,方法上统一用@GetMapping、@PostMapping这类衍生注解,不要方法上也写@RequestMapping。这么做读写扫视代码的效率会高很多。不过要注意的是,如果你在类上的@RequestMapping里指定了method,这个方法级别的约束会对子级产生影响,平时几乎没人这么干,但遇到怪异的bug时要能想到这一层。
5.2 RESTful风格的映射设计约定
RESTful风格在SpringMVC中的实现,本质就是路径表达资源、HTTP方法表达动作。例如订单资源Order:
| 操作 | 请求方式 | 路径 | 响应 |
|---|---|---|---|
| 查询订单列表 | GET | /orders | 订单列表JSON |
| 查询单个订单 | GET | /orders/{id} | 单个订单JSON |
| 创建订单 | POST | /orders | 创建结果 |
| 更新订单 | PUT | /orders/{id} | 更新结果 |
| 删除订单 | DELETE | /orders/{id} | 删除结果 |
配合代码实现,路径参数用@PathVariable获取:
@RestController @RequestMapping("/orders") public class OrderController { @GetMapping("/{id}") public Order getOrder(@PathVariable Long id) { return orderService.getById(id); } @PutMapping("/{id}") public Order updateOrder(@PathVariable Long id, @RequestBody Order order) { order.setId(id); return orderService.update(order); } @DeleteMapping("/{id}") public void deleteOrder(@PathVariable Long id) { orderService.delete(id); } }实际落地时有一个经验:RESTful风格是项目内部的约定,不是强制标准。团队里如果没有统一的接口规范,为了追求RESTful而把接口拆得很碎,反而会提高前端联调成本。我的习惯是——对外API严格按REST语义设计,后端内部模块间的调用优先保证清晰简单。
5.3 衍生注解使用时的几个注意事项
使用衍生注解时最容易犯的错误有三类:
第一,把@GetMapping和@RequestMapping混着用,一个方法上用GetMapping约定GET,另一个方法上用RequestMethod.POST,风格割裂。建议一个团队内统一选用一种方式,互相review代码时也容易对齐。
第二,类上写了@RequestMapping("/user"),方法上又写@GetMapping("/user"),实际访问路径变成了/user/user,这类重复前缀问题是新手上路的经典bug。
第三,忽视了@RequestMapping(method = ...)和衍生注解在编译器层面的区别。前者是普通注解,后者是组合注解。对于使用Spring的原生扫描机制来说没有区别,但在某些自定义注解处理器(AOP切面、自定义校验器)中,如果你用AnnotationUtils.findAnnotation去获取组合注解的元注解信息,需要确保自己用的是Spring的注解工具而不是JDK原生反射,否则组合注解的属性会拿不到值。
6. 路径匹配与参数绑定的隐藏细节:从Ant风格到类型转换
6.1 路径通配符与匹配优先级
SpringMVC的路径匹配支持Ant风格通配符:?匹配单个字符,*匹配任意数量的字符(不跨目录),**匹配任意数量的字符(可以跨目录)。
@GetMapping("/user/?") // 匹配 /user/a,不匹配 /user/ab @GetMapping("/user/*") // 匹配 /user/a,不匹配 /user/a/b @GetMapping("/user/**") // 匹配 /user/a/b/c路径匹配的优先级遵循"最长匹配优先"原则。两个映射条件都能匹配到同一个请求时,更具体的那个胜出。例如同时存在/user/*和/user/detail,访问/user/detail时命中后者,因为精确路径的匹配度高于通配符。
值得注意的是,SpringBoot 2.6版本默认把spring.mvc.pathmatch.matching-strategy改成了PathPatternParser,与旧版AntPathMatcher在匹配规则上有细微差异。如果你的项目从旧版本升级到SpringBoot 2.6+后,突然发现某些接口404了,第一反应应该查的就是这个配置项。这也是近两年SpringMVC升级时最常见的坑之一。
6.2 @PathVariable与@RequestParam的取舍
@PathVariable从URL路径模板中取值,@RequestParam从查询字符串或表单中取值。看一个对比示例:
@GetMapping("/order/{orderId}") public Order getByPath(@PathVariable("orderId") Long id) { // GET /order/1001 } @GetMapping("/order/query") public Order getByParam(@RequestParam("id") Long id) { // GET /order/query?id=1001 }选择哪一种?这取决于你的URL语义设计。路径参数适合表达层级资源,查询参数适合表达筛选条件。如果查询条件有多个,比如分页、排序、状态过滤,用@RequestParam是更自然的选择。不过一个方法里参数个数超过4个时,建议封装成一个查询对象,避免方法签名变得拥挤。SpringMVC支持直接用一个POJO接收查询参数,字段名与参数名一一对应,这也是很多人喜欢用的简洁方式:
@GetMapping("/order/search") public List<Order> search(OrderQuery query) { // OrderQuery 字段: keyword, status, page, size }6.3 类型转换系统的运转机制
一个容易被忽略但非常重要的机制是:SpringMVC的参数绑定之所以能自动完成,背后有一套完整的类型转换体系。@RequestParam("id") Long orderId能拿到正确的Long,不是request.getParameter返回字符串之后强制转型,而是通过ConversionService完成的。ConversionService内部有大量的Converter,覆盖字符串与数字、日期、布尔、枚举、集合类型的双向转换。
如果你自定义了一个复杂类型,希望在参数绑定阶段自动转换,可以实现Converter接口并注册到ConversionService。SpringBoot中注册的方式有两种:一种是在WebMvcConfigurer里添加自定义Formatter,另一种是直接把Converter注册成Bean。用得比较多的场景是参数ObjectId类型、加密ID的解密绑定等。
如果转换失败,SpringMVC会抛出MethodArgumentTypeMismatchException,最终表现成400错误。这个异常信息有时候会比较笼统,排查时可以优先检查类型转换器是否覆盖了你使用的目标类型。如果你把日期字符串"2024-01-15"绑定到LocalDate参数上,SpringBoot默认已经内置了支持ISO格式的Converter;但如果是"2024/01/15"这种斜杠格式,就必须自己注册Converter了。
7. Spring如何识别你写的注解:扫描、注册与映射建立的底层逻辑
7.1 组件扫描与候选组件识别
从热搜词里能看到"java注解处理器""spring注解"被反复搜索,说明大家对Spring的注解工作机制既有兴趣又有困惑。归纳起来,从你写下一个@Controller到它能处理请求,要经历三个阶段:扫描、注册、映射。
扫描阶段的核心是ClassPathBeanDefinitionScanner。Spring容器启动时,会扫描指定包路径下的所有.class文件,读取字节码中的注解信息,判断该类是否是候选组件(Candidate Component)。判断规则中有一条:类上标注了@Component及其派生注解(@Service、@Repository、@Controller、@RestController、@Configuration),就会被记录为候选类。扫描器内部通过AnnotationTypeFilter做元注解匹配,给一个类型过滤器,传入目标注解类型,它会检查候选类是否直接或间接标注了该注解。
7.2 BeanDefinition的注册与Bean实例化
扫描到候选类之后,Spring会为每个候选类生成一个BeanDefinition,里面保存类的全限定名、作用域、懒加载标记、初始化方法等元信息。然后把这些BeanDefinition注册到BeanDefinitionRegistry中。到这里,容器只是"知道"有一个类叫OrderController,还没有真正实例化。
真正的实例化发生在容器刷新阶段的finishBeanFactoryInitialization环节,也就是懒加载机制控制的地方。默认情况下SpringMVC的Controller是单例对象,容器启动时就会创建。要注意的是,Controller的实例化时机在SpringBoot中可能早于某些自定义配置类的后置处理,如果你在Controller的字段上注入了一些依赖于外部属性的配置,要确保属性来源(比如Nacos配置中心)在容器刷新前已经加载完毕,否则会出现注入值为null的诡异问题。
7.3 RequestMappingHandlerMapping如何建立映射表
容器创建完Controller实例之后,关键的映射建立工作发生在RequestMappingHandlerMapping的初始化阶段。这个组件实现了InitializingBean接口,在afterPropertiesSet方法中会调用initHandlerMethods,它会遍历容器中所有的Bean,筛出标注了@Controller或@RequestMapping的类,然后解析类上的@RequestMapping元信息和所有方法上的@RequestMapping/衍生注解,组装成一个RequestMappingInfo对象。
RequestMappingInfo包含了六个要素:路径模式、HTTP方法、参数条件、请求头条件、consumes条件、produces条件。这些要素组合在一起,构成了一个完整的映射条件。MappingRegistry(映射注册表)最终以路径模式为Key,以MappingRegistration为Value,存储了所有映射关系。
还记得前面提到的方法级@RequestMapping(method = RequestMethod.GET)吗?在构建RequestMappingInfo时,Spring会通过AnnotatedElementUtils去合并类级别和方法级别的条件。类上的路径会作为前缀拼接到方法路径前,类上的条件如果与方法上的条件不冲突,会被合并在一起参与最终匹配。这就是拼接URL的底层来源。
7.4 匹配请求时的顺序与异常处理
请求到达DispatcherServlet后,RequestMappingHandlerMapping根据请求的路径、HTTP方法、参数条件,在MappingRegistry中查找匹配的映射。精确路径优先于通配符路径,方法匹配优先于条件宽松的匹配。如果没有找到映射:
- 优先级最高的是404错误,但如果你定义了
@ControllerAdvice全局异常处理器,404并不是以异常形式抛出的——它由DispatcherServlet内部捕获后走默认的NoHandlerFoundException处理。 - 如果找到了映射但方法不受支持,会抛出
HttpRequestMethodNotSupportedException,最终表现为405。 - 如果路径匹配了但参数条件不满足,会由
ServletRequestBindingException的子类处理。
了解这些异常类型和它们触发的阶段,排查错误时会非常有帮助。很多人看到404直接怀疑Nginx配置,看到405怀疑前端请求方式不对,但有时候根因就在MappingRegistry里映射条件设置得过宽或过严。
8. 实际开发中@Mapping系列注解的高频踩坑点与排查思路
8.1 Ambiguous mapping:映射冲突的元凶
错误日志长这样:Ambiguous mapping. Cannot map 'xxxController' method ... There is already 'yyyController' ... mapped.
这个错误是SpringMVC开发中的老朋友。原因非常直接:两个方法最终生成的RequestMappingInfo完全一致,重复映射。常见诱发场景有三个:
- 微服务模块之间复制Controller代码时忘了改路径。
- 类上写了
@RequestMapping("/user"),方法上又写了@GetMapping("/user/list"),另一个类上写了@RequestMapping("/user"),方法上写了@GetMapping("/list"),两者实际都能匹配/user/list,于是冲突。 - 用通配符时路径叠加导致不同写法匹配到同一个实际URL。
排查方式:
- 阅读报错信息中列出的两个方法和类名,确认是否真的在同一个项目中。
- 列出两个方法的完整URL(把类前缀和方法路径拼接起来),对比是否完全一致。
- 删掉多余的映射,或者给其中一个方法换更精确的路径。
8.2 静态资源404与DispatcherServlet的捕获范围
Servlet映射路径配置为/时,DispatcherServlet会捕获所有请求,包括图片、CSS、JS静态资源。SpringMVC的默认处理是交给DefaultServletHttpRequestHandler,但前提是你配置了<mvc:default-servlet-handler/>或者在Java配置中放行静态资源路径。
在SpringBoot中,静态资源默认放在classpath:/static下,映射路径为/**,规则略有不同。但如果你用了@EnableWebMvc注解,SpringBoot的自动配置会被屏蔽,静态资源映射就失效了,这也是一个经典坑。遇到页面样式全丢的问题时,先检查配置类上有没有这个注解。
8.3 参数绑定时类型转换失败后的排查顺序
请求参数"abc"绑定到Long类型参数,SpringMVC抛出一长串TypeMismatchException的嵌套异常链,新手经常看得一头雾水。我的排查顺序是固定的:
- 看异常最底层的cause,确认是NumberFormatException还是其他转换异常。
- 检查参数名与@RequestParam注解里的name是否一致(参数名大小写写错是最常见的)。
- 检查目标类型是否被ConversionService支持,必要时自定义Converter。
- 如果请求体是JSON,还要看序列化器的配置是否正确,比如LocalDateTime默认序列化格式是不是你预期的格式。
8.4 @RequestBody与@RequestParam混用导致的415/400
一个非常典型的现象:前端用axios传JSON数据,后端方法签名同时在用@RequestParam和@RequestBody,Content-Type没设置对,结果SpringMVC直接报415或400。
设定@RequestBody后,SpringMVC会读取整个请求体并交给HttpMessageConverter反序列化。如果Content-Type是application/x-www-form-urlencoded,而你的方法用的是@RequestBody,消息转换器找不到匹配的转换器,于是报415。而@RequestParam在JSON请求体下是取不到值的,因为表单参数解析器根本不读取JSON body。这个问题的根因不在于注解本身,而在于前后端对Content-Type的理解不一致。
我在项目中处理这类问题的原则是:写接口之前先在API文档里定清楚请求的Content-Type,后端用@RequestBody就要求前端严格设置application/json,用@RequestParam就要求用表单提交,混用的情况尽量少。
8.5 排查利器:开启SpringMVC日志与调试技巧
排查@Mapping相关问题时,善用日志能省一半时间。在application.yml里增加:
logging: level: org.springframework.web: DEBUG打开DEBUG后,RequestMappingHandlerMapping的初始化过程会打印所有注册的映射路径。请求进来时还会打印HandlerExecutionChain的匹配结果、参数解析过程、返回值处理过程。看到哪一步断了,问题就定位到了。另外一个技巧是直接实现WebMvcConfigurer并重写configurePathMatch方法,可以临时调整匹配策略来测试路径通配符的差异,不过生产环境不建议做这种变更。
SpringMVC这套映射注解体系从诞生到今天已经十多年了,设计得相当成熟,踩坑基本都可以在文档和源码里找到答案。真正考验开发者的,是对"请求如何进来、映射如何建立、参数如何绑定"这条链路有完整的画面感。有了这个整体认知,遇到任何奇怪的问题,都能顺着链路一步步回溯到根因,而不是靠猜。