1. 自定义的HandlerMapping为什么不生效:先到先得的DispatcherServlet
1.1 DispatcherServlet如何遍历HandlerMapping:一切优先级的本质
先说一个我自己的经历。前两年接手的项目中有一个特殊渠道的API网关模块,需要拦截一批/gateway/**请求做签名校验、路由转发,但又不想走@Controller那套标准流程。当时我照着网上教程写了一个自定义的HandlerMapping,信心满满地启动项目,结果请求发过去之后,日志里连我的getHandler方法都没进去。排查了半天才发现,问题不在代码,而在"优先级"。
Spring MVC处理一个请求时,不是"只有一个HandlerMapping在工作",而是DispatcherServlet拿到请求后,会拿着一个List<HandlerMapping>按顺序挨个询问:你能处理这个请求吗?能的话把你匹配到的HandlerExecutionChain给我。源码在DispatcherServlet#doDispatch里写得很直白:
HandlerExecutionChain mappedHandler = null; for (HandlerMapping mapping : this.handlerMappings) { if (logger.isTraceEnabled()) { logger.trace("Testing handler map [" + mapping + "]"); } mappedHandler = mapping.getHandler(request); if (mappedHandler != null) { break; } }这个for循环就是"优先级"的全部本质:谁在列表里排在前面,谁就先被询问;一旦有人返回非null,后面的人连上场机会都没有。
我那次的情况就是典型的"我的HandlerMapping排在最后,前面RequestMappingHandlerMapping已经把这个请求吃掉了"。RequestMappingHandlerMapping会先按@RequestMapping注解匹配一遍,我那批/gateway/**路径恰好有一个@RestController里的兜底映射,于是请求直接就进了Controller,我的自定义Mapping彻底成了摆设。
想修复,至少得搞清楚两件事:当前项目里注册了哪些HandlerMapping,以及它们各自的order是多少。只有把这两件事弄明白,后面的调整才不会瞎忙。
1.2 "被吃掉"的路径:一个典型的优先级冲突案例
再举一个更细的例子。假设你建了一个HealthCheckHandlerMapping,作用是把所有/healthz请求直接交给一个内置的Handler返回状态信息,代码如下:
@Component public class HealthCheckHandlerMapping extends AbstractHandlerMapping { private static final String PATH = "/healthz"; public HealthCheckHandlerMapping() { setOrder(99); // 我想在后面一点,不干扰正常业务 } @Override protected Object getHandlerInternal(HttpServletRequest request) throws Exception { if (PATH.equals(request.getRequestURI())) { return new HealthCheckHandler(); } return null; } }你设置了order = 99,觉得"排后面点应该没事"。但事实是,如果项目里有一个@RequestMapping("/{path}")这种通配Controller,或者某个拦截器把/healthz提前消费掉了,反过来如果它前面有一个order = 0的RequestMappingHandlerMapping,只要Controller里没有精确匹配到/healthz,通常倒是能轮到你的Mapping。
但真正的坑在另外一个方向:如果某个占位路径/{path}匹配成功,请求就不会再继续往你这边走。Spring Boot 3.x里RequestMappingHandlerMapping的order是0,几乎是"优先中的优先",想让它把路径让出来,得靠PathMatch配置改变匹配范围,而不是简单把自定义Mapping的order改成负数就算完事。
上面说的这种场景,就是典型的"优先级冲突":两个HandlerMapping都能处理同一个路径,但系统只会给排在前面的那个机会。理解了DispatcherServlet的这个for循环之后,下一步我们把Spring Boot 3.x默认注册的那些HandlerMapping家底翻出来,看看默认座次到底怎么排的。
2. Spring Boot 3.x的默认门派座次:每个HandlerMapping从哪来、排第几
2.1 Spring Boot 3.x默认注册了哪五个HandlerMapping
在Spring Boot的Web MVC自动配置体系里,WebMvcConfigurationSupport(Spring Framework层面)和EnableWebMvcConfiguration(Spring Boot层面)会往容器里注册一批HandlerMapping bean。以Spring Boot 3.x默认配置为例,常见的五个如下:
| HandlerMapping类名 | 默认order | 主要负责 |
|---|---|---|
RequestMappingHandlerMapping | 0 | 根据@RequestMapping等注解映射Controller方法 |
WelcomePageHandlerMapping | 1 | 处理欢迎页(如index.html等) |
BeanNameUrlHandlerMapping | 2 | 按Bean名称映射URL(如/myHandler对应名为/myHandler的Bean) |
RouterFunctionMapping | 3 | 处理RouterFunction函数式路由 |
SimpleUrlHandlerMapping(resource) | Integer.MAX_VALUE - 1 | 静态资源映射、视图控制器等 |
这个表不是Spring Boot官方文档里直接给出来的,但根据WebMvcConfigurationSupport和EnableWebMvcConfiguration里的setOrder调用可以确认。RequestMappingHandlerMapping在WebMvcConfigurationSupport里创建时会调用mapping.setOrder(0),WelcomePageHandlerMapping在Spring Boot的EnableWebMvcConfiguration里创建时会调用setOrder(1),BeanNameUrlHandlerMapping是setOrder(2),RouterFunctionMapping在Spring Boot这边被设置成setOrder(3),SimpleUrlHandlerMapping(ResourceHandlerMapping)则是Integer.MAX_VALUE - 1。
这里要单独提一下RouterFunctionMapping。在Spring Framework 6.x的WebMvcConfigurationSupport源码里,它的order其实也是0,会和RequestMappingHandlerMapping并列。Spring Boot为了避免冲突,在EnableWebMvcConfiguration中重定义时把order改成了3,注释大意是"确保它在RequestMappingHandlerMapping之后执行"。如果你哪天甩开Spring Boot自动配置,自己继承WebMvcConfigurationSupport搞定制,就得自己处理这种可能的平级冲突。
2.2 升级到3.x后排序发生的变化:从AntPathMatcher到PathPatternParser
Spring Boot 3.x底层是Spring Framework 6.x,这里有一个非常影响HandlerMapping行为的改动:默认的路径匹配器从AntPathMatcher换成了PathPatternParser。
PathPatternParser的出现不是为了调整优先级,而是为了性能优化。它会提前把URL模式解析成一个PathPattern对象,匹配时直接走编译后的结构,比AntPathMatcher每次匹配都字符串解析快不少。
但它改变了匹配语义,其中一个典型变化是尾斜杠的处理。
在Spring Boot 2.x时代,/foo和/foo/在很多默认配置下是可以互相匹配的,也就是/foo/能命中@RequestMapping("/foo")。而在Spring Boot 3.x中,默认不允许这种尾斜杠的隐式匹配。如果你在处理优先级冲突时,把某个自定义HandlerMapping的order调高,但它的匹配模式是用老的AntPathMatcher风格写的,比如:
mapping.setPattern("/gateway/**/");这个模式在PathPatternParser下的语义会和AntPathMatcher不一致。尤其当你的项目里还有别的Mapping也在等着处理类似路径时,一个调了优先级却用错匹配器的Mapping,会让整个路由结果变得非常迷惑——不是在排前面的Mapping吃掉请求,就是排后面的Mapping永远匹配不到。
所以我们在Spring Boot 3.x里谈"优先级调整",实际上要做两件事:一是调order,二是确认匹配器的行为没有因为框架升级而变味。
2.3 RequestMappingHandlerMapping的0号位为什么不能轻易动
RequestMappingHandlerMapping的order是0,这意味着所有@Controller里声明的路径天然排在最前面。这个设计是有意为之:大多数Web应用的业务入口就是Controller,它应该最先被尝试。
所以当你遇到"Controller里的/{path}通配路径把我自定义Mapping的精准路径抢走了"这种问题时,正确的解法不是把RequestMappingHandlerMapping的order改成负数——那会让整个项目的路由行为全乱掉。更合理的做法是:
- 用更细的
PathMatch规则,比如让Controller不再匹配某些前缀; - 或者改Controller本身的映射精度,比如把
/{path}改成/api/{path}; - 或者让自定义Mapping去处理Controller完全不碰的路径。
一句话:RequestMappingHandlerMapping的0号位是Spring MVC的根基,动它之前先想想有没有别的手段能达成目的。接下来我们进入正题,看看到底有哪些调整优先级的手段,以及每种手段适合什么场景。
3. 调整优先级真正落地:四个可行方案与迁移后遗症
3.1 方案一:自定义HandlerMapping直接setOrder
最直接的方式,就是自建一个HandlerMappingBean,在初始化时手动setOrder。比如要让一个处理工具类路径的Mapping排在最前面,防止被业务Controller吃掉:
@Configuration public class GatewayMappingConfig { @Bean public GatewayHandlerMapping gatewayHandlerMapping() { GatewayHandlerMapping mapping = new GatewayHandlerMapping(); mapping.setOrder(-100); mapping.setInterceptors(new Object[]{new GatewayAuthInterceptor()}); return mapping; } }这里把order设为-100,让它比RequestMappingHandlerMapping的0还要靠前,专门用来拦截/gateway/**。这个方案的优点是清晰直白,缺点也很明显:你得保证gatewayHandlerMapping的匹配逻辑足够谨慎,否则它会抢走一切它能匹配到的请求,连Controller都轮不到。
我之前踩过的坑是:order设置太激进,导致/gateway/health这种本来应该进Controller的请求也被Mapping拦走,后面排查时第一反应还以为是拦截器问题,查了半天才发现是order的锅。
如果你只需要处理少数固定路径,建议在getHandlerInternal里做精确匹配,而不是用模糊前缀。PathPattern这种"看起来能匹配"和"真的只会匹配到想要的请求"之间,往往差着一个通配符的细节。
3.2 方案二:用Spring Boot的条件装配覆盖默认Bean
Spring Boot的自动配置大量依赖@ConditionalOnMissingBean。也就是说,如果你在项目里自己声明了一个同类型的HandlerMappingBean,Spring Boot默认的那个就会被你的替代掉。
典型的做法是重新定义一个RouterFunctionMapping并设置自己的order:
@Configuration public class CustomRoutingConfig { @Bean public RouterFunctionMapping routerFunctionMapping() { RouterFunctionMapping mapping = new RouterFunctionMapping(); mapping.setOrder(2); // 设置RouterFunction等属性 return mapping; } }这样容器里就只有一个RouterFunctionMapping,并且order变成了你指定的值。
这个方案的"坑"在于,一旦你定义了自己的RouterFunctionMapping,Spring Boot默认注册的那份就没了。你不仅要设置order,还得手动把原本默认行为里需要的属性(比如RouterFunction、ApplicationContext等)都配置好,否则会出现"路由没生效但也没报错"的诡异情况。
所以我的使用建议是:除非你真的很清楚自己在干什么,否则不要轻易覆盖Spring Boot默认的HandlerMapping Bean。覆盖一个RouterFunctionMapping还算可控,覆盖RequestMappingHandlerMapping就非常容易把整个MVC配置带偏。
3.3 方案三:BeanPostProcessor统一扭转乾坤
如果项目里HandlerMapping的数量很多,你不想到处找Bean定义改order,可以写一个BeanPostProcessor,在所有HandlerMappingBean初始化完成之后统一调整:
@Component public class HandlerMappingOrderProcessor implements BeanPostProcessor { @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RouterFunctionMapping) { ((RouterFunctionMapping) bean).setOrder(5); } if (bean instanceof SimpleUrlHandlerMapping && beanName.startsWith("resource")) { ((SimpleUrlHandlerMapping) bean).setOrder(Integer.MIN_VALUE); } return bean; } }注意postProcessAfterInitialization的执行时机,是在Bean初始化之后、被DispatcherServlet收集之前。只要赶在DispatcherServlet初始化handlerMappings列表之前搞定,这个方案就有效。
这种方案的优点是集中管理,改起来方便;缺点是隐式。代码里看不到一个明确的配置,别人接手项目时很难发现"原来这里还有一套BeanPostProcessor在改订单"。如果你的团队里有人喜欢全局搜索,这个方案可以接受;如果是维护性差的项目,建议在类上写清楚注释。
3.4 方案四:修改PathMatch配置,从源头改变匹配行为
有时候你觉得"优先级不对",其实根因不是order,而是匹配范围重叠。Spring Boot 3.x里可以自定义WebMvcConfigurer,通过configurePathMatch来微调路径匹配行为:
@Configuration public class PathMatchConfig implements WebMvcConfigurer { @Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setUseTrailingSlashMatch(false); configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController.class)); } }常见操作包括:
setUseTrailingSlashMatch(false):彻底关闭尾斜杠隐式匹配,避免/foo/和/foo混淆;addPathPrefix:给某类Controller统一加路径前缀;- 通过
HandlerTypePredicate控制哪些类参与@RequestMapping匹配。
这几个配置的作用,是在"匹配发生之前"就缩小某个HandlerMapping的覆盖面,间接解决"两个Mapping都想抢同一个路径"的问题。它不直接改order,但对优先级冲突的解决往往更有用。我在实际项目中处理/{path}通配Controller与特定路径的冲突时,优先都会考虑用addPathPrefix这类手段划清边界,而不是去调RequestMappingHandlerMapping的order。
4. 优先级调整后最容易翻车的三个场景
4.1 WebSocket握手路径被高优先级Mapping误拦截
把某个HandlerMapping的order调高之后,一个常见问题是它会顺手拦下原本应该走WebSocket握手的路径。
WebSocket连接建立的握手请求是一个普通的HTTP GET请求,路径通常是/ws这类地址。如果你自定义的Mapping设置了order = -100,并且匹配规则比较宽,比如:
protected Object getHandlerInternal(HttpServletRequest request) { String path = request.getRequestURI(); if (path.startsWith("/ws")) { return someHandler; } return null; }那么浏览器发过来的WebSocket握手请求会被你的HandleMapping直接吃掉,WebSocketHandler根本收不到握手。表现就是:前端WebSocket一直连接失败,后端日志里甚至看不到握手相关报错,只看到你的自定义Handler在那反复处理/ws路径。
这个问题在排查时特别容易被忽略,因为WebSocket握手本身不依赖普通的Controller路由,而HandlerMapping的拦截属于"更早的一层"。我见过好几个人把锅甩给WebSocket配置,最后才发现是自定义Mapping的匹配规则太宽。
解决方式很简单:在自定义HandlerMapping里过滤掉WebSocket握手路径,或者把匹配逻辑收敛到明确的路径列表,别拿前缀一刀切。
4.2 静态资源Mapping被抬高后与@Controller打架
有人为了让某些目录的静态资源优先返回,会把SimpleUrlHandlerMapping的order从Integer.MAX_VALUE - 1往前调。这个操作非常容易引发事故。
举一个真实的例子。Spring Boot静态资源默认映射了/**,如果在WebMvcConfigurer#addResourceHandlers里注册了一个/assets/**目录,然后又把这个Mapping的order直接改成1,那所有/assets/**请求确实会优先走静态资源处理器。但如果你的Controller里恰好有个@GetMapping("/assets/info")接口,这个接口就再也进不去了。
这还不是最伤的。有些项目会把静态资源HandlerMapping的order设得非常高,结果连错误页、favicon等请求的走向都受影响。
我的建议是:静态资源应该保留在默认的最低优先级。如果你希望某类路径稳定返回静态文件,应该靠路径前缀的区分,而不是靠调整order来"抢权"。
4.3 RouterFunctionMapping的3号位:为什么不能把它提到前面
Spring Boot把RouterFunctionMapping的order设置成3,是要让它排在@Controller和BeanNameUrlHandlerMapping后面,避免函数式路由和注解路由冲突。
有人会想:既然RouterFunction写起来那么简洁,我干脆把RouterFunctionMapping挪到order为1甚至0,让它优先处理所有请求。这种想法很危险,因为一旦RouterFunctionMapping排在RequestMappingHandlerMapping前面,所有能被RouterFunction匹配到的请求都会绕过Controller里的同名映射,统一被函数式路由接管。项目中出现两个"看起来都能处理/users"的路由时,行为会完全取决于order大小,而不是你的调用意图。
更麻烦的是,如果RouterFunctionMapping被排到静态资源Mapping前面,"静态资源实际上也被函数式路由先过一遍"——如果函数式路由里写了一个宽泛的兜底路由,所有静态文件请求都会被截胡。
正确的姿势是保持3号位,或者只在确实需要"函数式路由优先于Controller"这种罕见需求时,再小心翼翼调整。而且做完调整后一定要跑一遍全量路由测试,确认没有串台。
5. 验证优先级是否生效:日志、测试与我的排序原则
5.1 如何确认当前所有HandlerMapping的order
调整完之后,最怕的不是没有效果,而是不知到底有没有生效。我一般用两种方式快速确认。
第一种是把DispatcherServlet的日志级别调到DEBUG,启动时会打印出它初始化的handlermappings列表。在application.yml里这样配:
logging: level: org.springframework.web.servlet: DEBUG启动日志里能找到类似这样的输出:
RequestMappingHandlerMapping@1234 as org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping, order=0 WelcomePageHandlerMapping@5678 as org.springframework.web.servlet.handler.WelcomePageHandlerMapping, order=1 ...第二种更灵活:写个ApplicationRunner,主动把容器里的所有HandlerMapping列出来:
@Component public class HandlerMappingDumper implements ApplicationRunner { private final ApplicationContext context; public HandlerMappingDumper(ApplicationContext context) { this.context = context; } @Override public void run(ApplicationArguments args) { String[] names = context.getBeanNamesForType(HandlerMapping.class); for (String name : names) { HandlerMapping mapping = context.getBean(name, HandlerMapping.class); if (mapping instanceof Ordered ordered) { System.out.println("HandlerMapping[" + name + "] order=" + ordered.getOrder()); } } } }这样直接把order打印出来,比翻日志更直观。注意ApplicationRunner的执行时机,通常够早,可以作为调试手段,不适合留在生产代码里。
5.2 用MockMvc写一条"路由归属"测试
确认order不等于确认行为,最好再写个MockMvc测试,直接验证某个请求最终落到哪个Handler上。
假设你希望/gateway/notify只被自定义的GatewayHandlerMapping处理,可以这么测:
@SpringBootTest @AutoConfigureMockMvc class RoutingPriorityTest { @Autowired private MockMvc mockMvc; @Test void gatewayPathShouldBeHandledByCustomMapping() throws Exception { mockMvc.perform(get("/gateway/notify")) .andExpect(handler().handlerType(GatewayHandler.class)) .andExpect(status().isOk()); } @Test void controllerPathShouldNotBeIntercepted() throws Exception { mockMvc.perform(get("/api/orders")) .andExpect(handler().handlerType(OrderController.class)) .andExpect(status().isOk()); } }handler().handlerType(...)是MockMvc里极具价值的断言,它能直接告诉你"本次请求实际命中了哪个Handler"。当你调完order,拿这几条关键路径的测试跑一遍,优先级是否生效、有没有误伤,一眼就知道。
我经历过最舒服的排查,就是项目里有这样一组路由回归测试。改完HandlerMapping优先级,五分钟之内就能确认所有关键路径都没跑偏。
5.3 我在实际项目中用的排序原则与避坑清单
根据这些年的实际折腾,我整理了一套自己的排序原则,不一定适合所有人,但至少能帮你规避大多数日后的"灵异事件":
| 优先级次序 | HandlerMapping类型 | 备注 |
|---|---|---|
| -1000 ~ -1 | 特定前置Mapping(如网关鉴权、灰度标记) | 匹配规则必须极其精确 |
| 0 | RequestMappingHandlerMapping | 一般不要动 |
| 1 | WelcomePageHandlerMapping | 保持默认 |
| 2 | BeanNameUrlHandlerMapping | 保持默认 |
| 3 | RouterFunctionMapping | 尽量不要调到0 |
| 之后 | 自定义业务Mapping | 确保不会与Controller路径重叠 |
| 最后 | SimpleUrlHandlerMapping(静态资源) | 保持最低优先级 |
避坑清单:
- 不要为了"让自定义Mapping优先"而无脑把order设成
Integer.MIN_VALUE,先把匹配范围缩小; - 升级到Spring Boot 3.x后,重新确认一遍所有HandlerMapping的匹配器,
PathPatternParser和AntPathMatcher的差异会骗人; - 调整任何优先级,都要同步更新MockMvc路由测试,没测试就别调;
- 如果发现请求的走向总是和自己想的不一样,先打印handlerMappings列表,别猜。
调整HandlerMapping优先级这件事,说实话不复杂,但"为什么一定要调"往往比"怎么调"更难想清楚。大多数时候,合理的路径规划和PathMatch配置就能绕开优先级冲突,真正需要动order的场景其实很少。我个人的体会是:每改一次优先级,就当是给路由体系做了一次小手术,术后必须验证,否则谁也不知道哪个请求会在某个角落里悄悄跑偏。