1. 为什么每个Spring开发者都该啃一次HandlerMapping源码
做Java Web开发的朋友,Spring MVC那套东西天天在用,@RequestMapping、@GetMapping、@RestController这些注解闭着眼睛都能敲出来。但真要问一句“一个HTTP请求进来,Spring到底是怎么找到对应方法的”,能说清楚的人其实不多。我见过不少工作三四年的开发者,写业务代码没问题,一旦遇到请求404、映射冲突、拦截器不生效这类问题,就只能靠猜和试,效率极低。
HandlerMapping就是Spring MVC里负责“请求到处理器映射”的核心接口。它做的事情听起来很简单:给定一个HttpServletRequest,返回一个HandlerExecutionChain。但这个简单接口背后,牵扯出的是整个Spring MVC最精妙的设计之一——策略模式、适配器模式、责任链模式在这里交织,RequestMappingHandlerMapping、SimpleUrlHandlerMapping、BeanNameUrlHandlerMapping这些实现类各有各的适用场景。
这篇文章我会从源码级别把HandlerMapping拆开讲透。不是那种贴一段源码翻译一遍的流水账,而是结合我这些年排查线上问题的真实经验,把“为什么这么设计”“什么时候会踩坑”“怎么自定义扩展”这些真正有价值的东西讲清楚。适合已经会用Spring MVC、想往深里走一步的开发者,也适合正在准备面试、想把Spring底层原理讲明白的朋友。读完你至少能搞清楚:请求映射的完整链路、getHandler方法的执行逻辑、映射注册的时机、以及几个高频踩坑点的根因。
2. HandlerMapping接口的整体设计与思路拆解
2.1 接口定义里藏着的设计哲学
先看HandlerMapping接口本身,Spring 5.x之后的定义非常简洁:
public interface HandlerMapping { HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception; }就一个方法。但别小看这一个方法,它把“请求怎么找到处理器”这件事完全抽象出来了。接口只关心输入(request)和输出(HandlerExecutionChain),中间怎么找、用什么策略找,全部交给实现类。这就是典型的策略模式——不同的映射策略可以自由替换,互不干扰。
为什么Spring要这么设计?因为请求到处理器的映射方式实在太多了。早期有基于Bean名称的、基于URL的,后来有了注解驱动的,还有函数式端点的。如果把这些逻辑全塞在一个类里,用if-else判断,代码会烂到没法维护。抽象成接口之后,每种映射方式一个实现类,各管各的,新增一种映射方式只需要加一个实现类,符合开闭原则。
HandlerExecutionChain这个返回值也值得说道。它不是单纯返回一个Handler对象,而是返回了一个“执行链”,里面包含handler本身和一堆拦截器(Interceptor)。这意味着映射的过程不只是“找到方法”,还要把跟这个请求相关的拦截器组装好。这个设计把“映射”和“拦截”两个关注点优雅地结合在了一起,后面讲getHandler执行流程时会详细展开。
2.2 继承体系:一张图看清所有实现类
HandlerMapping的直接实现和间接实现类不少,我按功能把它们分成几类,这样理解起来更清晰:
| 实现类 | 类型 | 核心用途 | 是否默认启用 |
|---|---|---|---|
RequestMappingHandlerMapping | 注解驱动 | 处理@RequestMapping系列注解 | 是 |
SimpleUrlHandlerMapping | URL匹配 | 手动配置URL到Bean的映射 | 否(需手动配置) |
BeanNameUrlHandlerMapping | Bean名称匹配 | Bean名称以/开头则作为URL | 是(默认) |
RouterFunctionMapping | 函数式 | 处理函数式端点路由 | 是(WebFlux/新版本MVC) |
WelcomePageHandlerMapping | 特殊 | 处理欢迎页 | 是 |
这里有个容易混淆的点:AbstractHandlerMapping是抽象基类,它实现了HandlerMapping接口,把拦截器管理、默认处理器、路径匹配器这些公共逻辑都封装好了。上面那些具体实现类基本都继承自它。所以真正读源码的时候,AbstractHandlerMapping.getHandler()才是重头戏,子类主要覆写的是getHandlerInternal()方法。
提示:很多人读源码时直接跳到
RequestMappingHandlerMapping,结果发现找不到核心逻辑,就是因为核心逻辑在父类AbstractHandlerMapping里。先读父类,再读子类,顺序不能反。
2.3 为什么默认注册了这么多HandlerMapping
Spring MVC启动时会自动注册多个HandlerMapping,它们有优先级顺序(通过Order接口控制)。请求进来后,会按顺序遍历所有HandlerMapping,第一个能返回非null结果的就被采用。
这个设计的好处是职责分离。比如RequestMappingHandlerMapping的order是0,优先级最高,专门处理注解映射;BeanNameUrlHandlerMapping的order是2,兜底处理那些用Bean名称做URL的老式配置。这样注解映射和传统配置可以共存,互不冲突。
但这也带来一个坑:如果你自定义了一个HandlerMapping但没设置order,它可能被排到最后,导致永远不生效。我见过有同事自定义映射器排查了半天,最后发现是order没设对。这个后面在常见问题里会详细讲。
3. 核心细节解析与实操要点
3.1 AbstractHandlerMapping.getHandler的执行链路
这是整个映射过程的主干,我把它拆成几个关键步骤:
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { // 第一步:调用子类实现的getHandlerInternal,拿到handler Object handler = getHandlerInternal(request); if (handler == null) { handler = getDefaultHandler(); } if (handler == null) { return null; } // 第二步:如果handler是字符串,说明是Bean名称,去容器里取 if (handler instanceof String) { String handlerName = (String) handler; handler = obtainApplicationContext().getBean(handlerName); } // 第三步:组装执行链,把拦截器加进去 HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request); // 第四步:处理CORS配置 if (CorsUtils.isCorsRequest(request)) { // ... CORS相关处理 } return executionChain; }第一步的getHandlerInternal是抽象方法,由子类实现。RequestMappingHandlerMapping在这里做的事情是:从request里取出URL路径,然后去它维护的映射注册表(MappingRegistry)里查找匹配的HandlerMethod。这个查找过程涉及路径匹配、HTTP方法匹配、参数条件匹配、请求头条件匹配等一系列复杂逻辑。
第二步处理字符串类型的handler,这是为了兼容BeanNameUrlHandlerMapping这种用Bean名称做映射的场景。SimpleUrlHandlerMapping返回的也是Bean名称字符串。
第三步组装执行链,会把所有匹配的拦截器按顺序加进去。拦截器的匹配也是基于路径的,MappedInterceptor会根据URL模式判断是否应用。
3.2 RequestMappingHandlerMapping的映射注册时机
RequestMappingHandlerMapping的映射关系不是请求来了才建立的,而是在Spring容器启动、Bean初始化完成后就注册好了。具体时机是afterPropertiesSet()方法被调用时(实现了InitializingBean接口)。
注册过程大致是这样的:扫描所有Bean,找出带有@Controller或@RequestMapping注解的类,然后解析类级别和方法级别的@RequestMapping注解,把每个方法封装成一个RequestMappingInfo对象,存到MappingRegistry里。
RequestMappingInfo是映射条件的封装,它包含了:
PathPatternsRequestCondition:URL路径条件RequestMethodsRequestCondition:HTTP方法条件(GET/POST等)ParamsRequestCondition:请求参数条件HeadersRequestCondition:请求头条件ConsumesRequestCondition:Content-Type条件ProducesRequestCondition:Accept条件
这些条件组合起来,构成了一个完整的映射规则。请求进来时,会拿request的各项属性去跟这些条件逐一匹配。
注意:
MappingRegistry内部用的是MultiValueMap,key是RequestMappingInfo,value是对应的MappingRegistration列表。这意味着同一个映射条件可以对应多个处理方法(虽然实际开发中很少这么干),查找时会返回第一个匹配的。
3.3 路径匹配的演进:从AntPathMatcher到PathPattern
Spring 5.3之前,路径匹配用的是AntPathMatcher,支持*、**、?这些通配符。5.3引入了PathPattern,性能更好,而且解析后的路径模式可以复用,不用每次请求都重新解析。
两者的主要区别:
| 特性 | AntPathMatcher | PathPattern |
|---|---|---|
| 性能 | 每次匹配都要解析模式 | 模式预解析,匹配更快 |
| 语法 | 支持**匹配多级路径 | 同样支持,但语义更严格 |
| 尾部斜杠 | 默认匹配 | 默认不匹配,需显式配置 |
| 使用场景 | 传统MVC | WebFlux及新版MVC |
这个变化带来的一个实际影响是:升级到Spring 5.3+后,如果URL末尾多了个斜杠,可能突然就404了。因为PathPattern默认不匹配尾部斜杠。解决办法是配置PathMatchConfigurer,设置setUseTrailingSlashMatch(true)。
3.4 拦截器是怎么被组装进执行链的
getHandlerExecutionChain方法负责把拦截器加进HandlerExecutionChain。逻辑不复杂,但有几个细节值得注意:
protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) { HandlerExecutionChain chain = (handler instanceof HandlerExecutionChain ? (HandlerExecutionChain) handler : new HandlerExecutionChain(handler)); for (Interceptor interceptor : this.adaptedInterceptors) { if (interceptor instanceof MappedInterceptor) { MappedInterceptor mappedInterceptor = (MappedInterceptor) interceptor; if (mappedInterceptor.matches(request)) { chain.addInterceptor(mappedInterceptor.getInterceptor()); } } else { chain.addInterceptor(interceptor); } } return chain; }关键点:MappedInterceptor会根据路径判断是否应用,普通的HandlerInterceptor则无条件加入。这意味着如果你直接实现HandlerInterceptor接口并注册为Bean,它会对所有请求生效;如果用MappedInterceptor包装,就可以控制生效范围。
实操心得:很多人在拦截器里做权限校验,结果发现静态资源请求也被拦截了,导致页面加载异常。正确做法是用
MappedInterceptor或者重写preHandle方法时排除静态资源路径。我一般建议用WebMvcConfigurer.addInterceptors来注册,它内部会自动包装成MappedInterceptor,可以精确控制路径。
4. 实操过程与核心环节实现
4.1 手写一个自定义HandlerMapping
光看源码不够,自己动手写一个才能真理解。下面我实现一个简单的HandlerMapping,功能是:所有以/api/time开头的请求,都返回当前时间戳。
public class TimeHandlerMapping extends AbstractHandlerMapping { @Override protected Object getHandlerInternal(HttpServletRequest request) throws Exception { String path = request.getRequestURI(); if (path.startsWith("/api/time")) { return new TimeHandler(); } return null; } static class TimeHandler implements HttpRequestHandler { @Override public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"timestamp\":" + System.currentTimeMillis() + "}"); } } }然后注册到Spring容器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Bean public TimeHandlerMapping timeHandlerMapping() { TimeHandlerMapping mapping = new TimeHandlerMapping(); mapping.setOrder(1); // 设置优先级,在RequestMappingHandlerMapping之后 return mapping; } }这里有几个关键点:
第一,继承AbstractHandlerMapping而不是直接实现HandlerMapping接口。因为父类帮我们处理了拦截器、CORS、默认处理器这些逻辑,只需要实现getHandlerInternal就行。
第二,setOrder(1)很重要。RequestMappingHandlerMapping的order是0,我们的自定义映射器设为1,意味着注解映射优先,匹配不到才走我们的逻辑。如果设为-1,就会抢在注解映射前面,可能导致@RequestMapping失效。
第三,返回的handler实现了HttpRequestHandler接口。Spring MVC对不同类型的handler有不同的适配器(HandlerAdapter),HttpRequestHandler有专门的HttpRequestHandlerAdapter来处理。如果返回的是普通对象,就需要SimpleControllerHandlerAdapter或者RequestMappingHandlerAdapter来适配。
4.2 调试映射注册过程
想搞清楚映射到底是怎么注册的,最直接的办法是打断点。在RequestMappingHandlerMapping.afterPropertiesSet()里下断点,启动应用,你会看到它遍历所有Bean,解析注解,构建RequestMappingInfo的完整过程。
如果不想打断点,也可以写个测试类,注入RequestMappingHandlerMapping,然后调用getHandlerMethods()方法,它会返回所有已注册的映射关系:
@SpringBootTest public class MappingDebugTest { @Autowired private RequestMappingHandlerMapping handlerMapping; @Test public void printAllMappings() { Map<RequestMappingInfo, HandlerMethod> methods = handlerMapping.getHandlerMethods(); methods.forEach((info, method) -> { System.out.println(info + " => " + method); }); } }这个输出在排查映射冲突时特别有用。我曾经遇到过一个线上问题:某个接口偶尔404,偶尔正常。用这个方法打印出所有映射后发现,有两个Controller定义了相同的URL路径,但HTTP方法条件不同,导致请求路由到了错误的处理器。这种问题光看代码很难发现,打印映射表一目了然。
4.3 映射冲突的检测与处理
Spring在注册映射时会检测冲突。如果两个方法映射到完全相同的条件,启动时会直接抛异常。但如果条件部分重叠(比如一个匹配/user/*,一个匹配/user/list),Spring不会报错,运行时按优先级选择。
优先级规则大致是:路径越具体优先级越高。/user/list比/user/*更具体,所以会优先匹配前者。但这个规则不是绝对的,还跟注册顺序有关。
踩坑记录:有一次我在一个老项目里加了个
@GetMapping("/user/{id}"),结果发现原来@GetMapping("/user/list")的接口不生效了。原因是/user/{id}被注册在了/user/list前面,而{id}这种路径变量在匹配时优先级判断比较复杂。解决办法是调整方法在类中的顺序,或者用更精确的路径。最稳妥的方式是避免这种模糊匹配,路径设计上就区分开。
5. 常见问题与排查技巧实录
5.1 请求404的排查思路
404是映射问题里最常见的症状。排查时按这个顺序来:
- 确认URL是否正确:包括大小写、尾部斜杠、上下文路径。Spring 5.3+对尾部斜杠敏感,
/user和/user/可能结果不同。 - 确认HTTP方法是否匹配:
@GetMapping只接受GET,用POST请求会返回405而不是404,但有些配置下可能表现为404。 - 确认映射是否注册成功:用上面说的
getHandlerMethods()打印映射表。 - 确认HandlerMapping的order:自定义映射器如果order设得太小,可能拦截了本该由注解映射处理的请求。
- 确认拦截器是否放行:拦截器的
preHandle返回false会导致请求被中断,表现可能像404。
5.2 映射冲突速查表
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 启动报错“Ambiguous mapping” | 两个方法映射条件完全相同 | 看异常信息里的方法签名 | 修改其中一个的路径或方法 |
| 接口偶尔404 | 路径模糊匹配冲突 | 打印映射表,检查重叠路径 | 使用更精确的路径 |
| 新加接口不生效 | 映射器order问题 | 检查自定义HandlerMapping的order | 调整order值 |
| 静态资源404 | 静态资源映射被覆盖 | 检查addResourceHandlers配置 | 调整资源路径或映射器order |
| 尾部斜杠导致404 | PathPattern默认不匹配 | 测试带斜杠和不带斜杠的URL | 配置setUseTrailingSlashMatch(true) |
5.3 几个容易忽略的细节
细节一:@RequestMapping在类级别和方法级别的组合。类级别定义基础路径,方法级别定义子路径,最终路径是两者拼接。如果类级别没写路径,方法级别直接写完整路径。这个大家都知道,但有个坑:类级别和方法级别的produces、consumes条件也会合并,如果冲突会导致映射不生效。
细节二:params条件的匹配。@GetMapping(value="/user", params="type=admin")表示只有请求参数type=admin时才匹配。如果请求里没有type参数,或者值不是admin,这个映射就不会被选中。这个特性在做接口版本控制时很有用,但容易忘记。
细节三:headers条件的匹配。类似params,可以基于请求头做映射。比如根据Accept头返回不同格式的数据。但要注意,如果请求头不匹配,会直接404,而不是返回默认格式。
细节四:组合注解的解析。@GetMapping、@PostMapping这些组合注解本质上是被@RequestMapping元注解标注的。Spring在解析时会递归查找元注解,所以自定义组合注解也能被正确识别。但自定义注解时要注意@AliasFor的使用,否则属性覆盖会出问题。
5.4 性能相关的注意事项
HandlerMapping的查找是在每次请求时都会执行的,所以性能很关键。RequestMappingHandlerMapping内部用了缓存机制:第一次查找某个路径时,会把匹配结果缓存起来,后续相同路径直接走缓存。
但这个缓存有个前提:路径必须是“直接匹配”的,也就是没有路径变量、没有通配符。如果路径里包含{id}这种变量,每次请求的URL都不同,缓存就无法命中,每次都要走完整的匹配流程。
实操建议:对于高频接口,尽量避免在路径中使用路径变量,或者把路径变量放在最后。比如
/user/{id}/detail比/user/detail/{id}的缓存命中率更低,因为前者在{id}之前的部分是固定的,但整体路径仍然不同。如果性能要求极高,可以考虑用SimpleUrlHandlerMapping手动配置精确路径,它的查找是O(1)的。
6. 从源码到实战的几点个人体会
聊了这么多源码和实操,最后说几个我自己的体会。HandlerMapping这套设计最值得学习的地方,是它把“变化”和“不变”分离得很彻底。不变的流程(获取handler、组装执行链、处理CORS)放在抽象基类里,变化的逻辑(怎么找handler)留给子类实现。这种模板方法模式的应用,在Spring里随处可见,理解了这一套,再看其他模块的源码会轻松很多。
另外,读源码不要追求一次读懂所有细节。我第一遍读RequestMappingHandlerMapping的时候,被MappingRegistry、RequestMappingInfo、各种RequestCondition绕得头晕。后来换个思路,先搞清楚主干流程,再逐个击破细节,反而豁然开朗。建议你也这样,先跑通一个简单请求的完整链路,打断点跟一遍,比死磕代码有效得多。
还有一个实用技巧:遇到映射相关的问题,别急着改代码,先把getHandlerMethods()的输出打出来看看。很多问题看一眼映射表就清楚了,比瞎猜快得多。这个习惯帮我省了大量排查时间。