1. 从一次请求分发异常说起:为什么我要啃 HandlerMapping 源码
前段时间帮一个朋友排查线上问题,现象很典型:他自定义了一个注解,想通过注解把某些请求路由到特定的处理方法上,结果发现请求进来之后,要么 404,要么被路由到了一个完全不相干的 Controller 方法上。他一开始怀疑是注解没生效,后来又怀疑是包扫描路径不对,折腾了大半天,最后定位到问题出在请求映射的注册和查找环节——也就是HandlerMapping这条链路上。
这件事让我意识到,很多人对 Spring MVC 的理解停留在“写个@RequestMapping就能用”的层面,一旦路由行为不符合预期,就完全不知道从哪下手。而HandlerMapping恰恰是整个请求分发流程的“第一道关卡”:它决定了进来的这个 HTTP 请求,到底该交给哪个处理器方法去执行。你写的每一个@GetMapping、@PostMapping,最终都要经过它的手。
所以这篇内容,我想把HandlerMapping接口的源码从头到尾拆一遍。不是那种贴一段源码翻译一段注释的流水账,而是按照“它要解决什么问题、接口怎么设计的、核心实现类怎么干的、实际用的时候哪里容易踩坑”这个思路来讲。适合已经会用 Spring MVC、但对底层路由机制一知半解的开发者,也适合想自己扩展路由规则、写自定义 HandlerMapping 的同学。看完之后,你至少能做到两件事:第一,遇到路由相关的问题知道往哪个方向查;第二,有能力自己写一个能跑起来的自定义 HandlerMapping。
2. HandlerMapping 接口到底在抽象什么
2.1 接口定义里藏着的设计意图
先看接口本身。HandlerMapping的定义非常简洁,核心就一个方法:
public interface HandlerMapping { HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception; }就这么一个方法。但恰恰是这种极简的接口,背后藏着 Spring 一贯的设计哲学:把“请求到处理器的映射”这件事抽象成一个单一职责,至于你怎么映射、映射的规则是什么、映射的数据从哪来,接口一概不管。
这里有个关键点很多人会忽略:返回的不是HandlerMethod,而是HandlerExecutionChain。这个区别很重要。HandlerExecutionChain里面包了两样东西——一个是真正的处理器(handler),另一个是拦截器列表(interceptorList)。也就是说,HandlerMapping的职责不只是“找到谁来处理”,还包括“在处理前后要经过哪些拦截器”。这就解释了为什么拦截器的顺序、拦截器的匹配路径,本质上也是由 HandlerMapping 决定的。
我个人的理解是,这个设计把“路由”和“拦截”这两件本来耦合的事情,通过一个返回值统一了起来。你在配置拦截器的时候写的addPathPatterns,最终就是在这个环节被解析并组装进执行链的。
2.2 抽象类 AbstractHandlerMapping 补了哪些能力
光有接口没法直接用,Spring 提供了一个抽象基类AbstractHandlerMapping,把通用的逻辑都实现好了。它主要补了这么几块能力:
第一块是拦截器的管理。它内部维护了一个List<HandlerInterceptor>,并且提供了setInterceptors方法。在getHandler方法里,它会先调用子类实现的getHandlerInternal拿到处理器,然后如果处理器不为空,就把所有匹配的拦截器组装成HandlerExecutionChain返回。
第二块是默认的处理器兜底。它有一个defaultHandler属性,当子类找不到任何匹配的处理器时,可以返回这个默认处理器。这个机制在静态资源处理、欢迎页处理里用得比较多。
第三块是 CORS 配置的集成。跨域相关的CorsConfiguration也是在这一层被织入执行链的,这也是为什么你配置了跨域之后,不需要在每个 Controller 里单独处理。
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { Object handler = getHandlerInternal(request); if (handler == null) { handler = getDefaultHandler(); } if (handler == null) { return null; } // 如果是字符串,说明是 bean 名称,需要从容器里取 if (handler instanceof String) { String handlerName = (String) handler; handler = obtainApplicationContext().getBean(handlerName); } // 组装执行链,织入拦截器 HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request); // CORS 相关处理 // ... return executionChain; }注意这里getHandler被声明成了final,意味着子类不能覆盖整个流程,只能通过实现getHandlerInternal来定制“怎么找处理器”这一步。这种模板方法模式在 Spring 里到处都是,好处是流程稳定、扩展点清晰。
2.3 getHandlerInternal 才是子类的真正战场
所有具体的 HandlerMapping 实现,核心工作都集中在getHandlerInternal这个方法上。它的签名是:
protected abstract Object getHandlerInternal(HttpServletRequest request) throws Exception;返回类型是Object,不是HandlerMethod。为什么这么设计?因为处理器的类型可以很多样:可以是一个HandlerMethod(最常见的 Controller 方法),可以是一个HttpRequestHandler对象,可以是一个Servlet,甚至可以是任意一个被包装过的对象。这种宽松的返回类型,让 HandlerMapping 的适用范围远远超出了注解式 Controller。
理解了这一点,你就能明白为什么 Spring MVC 里会有那么多种 HandlerMapping 实现,它们本质上都是在用不同的策略回答同一个问题:这个请求该由谁来处理。
3. 三大核心实现类的分工与源码细节
3.1 RequestMappingHandlerMapping:注解式路由的主力
这是日常开发中最重要的一个实现类,负责处理@RequestMapping及其衍生注解(@GetMapping、@PostMapping等)。它的核心逻辑分两个阶段:启动时的注册阶段,和运行时的查找阶段。
注册阶段发生在容器启动时。它会扫描所有 Bean,找出带有@Controller或@RequestMapping的类,然后解析类级别和方法级别的注解,把每一个“请求条件 + 处理方法”的组合注册到一个MappingRegistry里。这个注册过程的核心方法是registerHandlerMethod,它会为每个方法创建一个RequestMappingInfo,然后存进一个多值映射结构。
这里有个细节值得说:MappingRegistry内部用了多个 Map 来存储映射关系,包括按路径直接匹配的、按类型匹配的等等。这种多级索引的设计,是为了在查找时能快速缩小范围,而不是每次都遍历所有注册的方法。
查找阶段就是getHandlerInternal被调用的时候。它会根据请求的 URL、方法、参数、头信息等,构造一个RequestInfo,然后去注册表里找匹配的RequestMappingInfo。匹配的过程涉及路径匹配、条件匹配等多个环节,最终返回一个HandlerMethod。
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception { String lookupPath = initLookupPath(request); this.mappingRegistry.acquireReadLock(); try { HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request); return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null); } finally { this.mappingRegistry.releaseReadLock(); } }注意这里的读写锁。因为注册表在启动后基本是只读的,但理论上支持动态注册,所以用了读写锁来保证并发安全。这个细节在你自己写 HandlerMapping 时值得借鉴。
3.2 SimpleUrlHandlerMapping:最直白的 URL 映射
如果说RequestMappingHandlerMapping是“智能路由”,那SimpleUrlHandlerMapping就是“手动挡”。它不解析任何注解,而是直接维护一个 URL 到处理器的映射表,你配置什么它就映射什么。
它的getHandlerInternal实现非常直接:拿请求路径去 Map 里查,查到了就返回,查不到就返回 null。路径匹配支持 Ant 风格的通配符,也支持精确匹配。
protected Object getHandlerInternal(HttpServletRequest request) throws Exception { String lookupPath = getUrlPathHelper().getLookupPathForRequest(request); Object handler = lookupHandler(lookupPath, request); if (handler == null) { // 尝试用默认处理器 Object rawHandler = null; if ("/".equals(lookupPath)) { rawHandler = getRootHandler(); } if (rawHandler == null) { rawHandler = getDefaultHandler(); } // ... } return handler; }这个类在实际项目里用得不多,但在处理静态资源、做简单的 URL 转发时非常方便。比如你想把/old-api/**全部转发到某个统一的处理器上,用它就比写一堆 Controller 方法清爽得多。
3.3 BeanNameUrlHandlerMapping:按 Bean 名称映射
这个实现类的逻辑是:如果一个 Bean 的名字是以斜杠开头的,就把它当作一个 URL 路径,注册为处理器。比如你定义了一个 Bean 叫/myHandler,那访问/myHandler就会路由到它。
它的查找逻辑就是拿请求路径去容器里找同名的 Bean。这个机制在早期的 Spring MVC 里用得比较多,现在基本被注解式取代了。但理解它有助于你明白一件事:HandlerMapping 的“映射依据”可以非常灵活,可以是注解、可以是配置、可以是 Bean 名称,甚至可以是数据库里的数据。
3.4 三者的优先级与协作关系
在默认配置下,Spring MVC 会注册多个 HandlerMapping,它们是有顺序的。RequestMappingHandlerMapping的 order 是 0,SimpleUrlHandlerMapping和BeanNameUrlHandlerMapping的 order 通常是更大的值。查找时按 order 从小到大依次尝试,谁先返回非 null 的处理器就用谁。
这个顺序很重要。如果你自定义了一个 HandlerMapping 但没设置 order,它可能被排在很后面,导致永远轮不到它。我踩过这个坑:写了个自定义映射想拦截特定前缀的请求,结果因为 order 太大,请求先被RequestMappingHandlerMapping匹配走了。
| 实现类 | 映射依据 | 默认 order | 典型场景 |
|---|---|---|---|
| RequestMappingHandlerMapping | 注解 | 0 | 常规 Controller |
| SimpleUrlHandlerMapping | 显式配置 | 较大 | 静态资源、URL 转发 |
| BeanNameUrlHandlerMapping | Bean 名称 | 较大 | 早期风格、简单映射 |
| 自定义 HandlerMapping | 自定义 | 需手动设置 | 特殊路由需求 |
4. 请求查找的完整链路:一次 getHandler 调用经历了什么
4.1 从 DispatcherServlet 到 HandlerMapping
请求进入DispatcherServlet的doDispatch方法后,第一件事就是调用getHandler(request)。这个方法会遍历容器里所有的 HandlerMapping,按 order 顺序逐个调用它们的getHandler。
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { if (this.handlerMappings != null) { for (HandlerMapping mapping : this.handlerMappings) { HandlerExecutionChain handler = mapping.getHandler(request); if (handler != null) { return handler; } } } return null; }这段代码看起来简单,但有个关键点:只要有一个 HandlerMapping 返回了非 null 的执行链,循环就结束了。这意味着 HandlerMapping 之间的匹配是“短路”的,前面的匹配上了,后面的就没机会了。这也是为什么 order 设置错误会导致自定义映射失效。
4.2 路径匹配的细节:Ant 风格与 PathPattern
在RequestMappingHandlerMapping内部,路径匹配经历了从 AntPathMatcher 到 PathPattern 的演进。早期版本用的是AntPathMatcher,它支持**、*、?等通配符,但性能一般,而且有些边界情况处理得不够好。
后来引入了PathPattern,它是预解析的,性能更好,语义也更清晰。比如/**和/*的区别,在 PathPattern 里定义得更明确。默认情况下,Spring Boot 2.6 之后已经切换到 PathPattern 了。
这个变化对实际开发的影响是:某些以前能匹配上的路径,现在可能匹配不上了。比如/**/foo这种模式,在 Ant 风格下能匹配多层路径,在 PathPattern 下行为可能不同。如果你从老版本升级上来发现路由 404,很可能就是这里的问题。
4.3 条件匹配:不只是看路径
RequestMappingInfo里封装的条件远不止路径。它还包括:
- 请求方法(GET、POST 等)
- 请求参数(params 条件)
- 请求头(headers 条件)
- 消费的媒体类型(consumes)
- 生产的媒体类型(produces)
查找时,这些条件会逐一比对。任何一个不满足,这个映射就不会被选中。这就解释了为什么同一个路径可以对应多个方法:只要它们的请求方法或参数条件不同就行。
// 这两个方法路径相同,但通过请求方法区分 @GetMapping("/user") public User getUser() { ... } @PostMapping("/user") public User createUser() { ... }如果两个方法的条件完全一样,启动时就会报错,提示存在歧义的映射。这个检查是在注册阶段做的,不是运行时才发现的。
4.4 匹配到多个时的排序规则
有时候一个请求可能同时满足多个映射的条件,比如一个精确路径的映射和一个通配符路径的映射。这时候 Spring 会按照一定的规则选出“最优”的那个。
排序的依据包括:路径的精确程度、通配符的数量、条件的多少等。精确路径优先于通配符路径,条件多的优先于条件少的。这个排序逻辑在RequestMappingInfo的compareTo方法里实现。
我遇到过一个案例:两个 Controller 分别映射了/api/user和/api/**,访问/api/user时,理论上应该匹配第一个,但实际却匹配到了第二个。排查后发现是其中一个映射多了一个params条件,导致排序结果和预期不一致。这种问题不看源码很难想明白。
5. 自己写一个 HandlerMapping:从需求到落地
5.1 什么场景下需要自定义
大部分情况下你不需要自定义 HandlerMapping,注解式路由已经够用了。但有几类场景,自己写一个会更优雅:
第一类是路由规则来自外部配置或数据库,而不是代码里的注解。比如多租户系统里,不同租户的路由规则可能不同,这时候用注解就不合适了。
第二类是需要对路由做特殊处理,比如根据请求头里的某个字段动态决定处理器,或者实现灰度发布的路由逻辑。
第三类是要接管某些特定前缀的请求,做统一的预处理或转发。
5.2 继承 AbstractHandlerMapping 的最小实现
自定义 HandlerMapping 最省事的方式是继承AbstractHandlerMapping,只需要实现getHandlerInternal方法。
public class CustomHandlerMapping extends AbstractHandlerMapping { private final Map<String, Object> handlerMap = new ConcurrentHashMap<>(); public void registerHandler(String path, Object handler) { handlerMap.put(path, handler); } @Override protected Object getHandlerInternal(HttpServletRequest request) throws Exception { String lookupPath = getUrlPathHelper().getLookupPathForRequest(request); return handlerMap.get(lookupPath); } }就这么几行,一个能用的自定义 HandlerMapping 就出来了。AbstractHandlerMapping会帮你处理拦截器组装、默认处理器兜底、CORS 等逻辑。
5.3 注册到容器并设置正确的 order
写完之后要注册到 Spring 容器里。如果你用 Spring Boot,可以通过配置类:
@Configuration public class CustomMappingConfig { @Bean public CustomHandlerMapping customHandlerMapping() { CustomHandlerMapping mapping = new CustomHandlerMapping(); mapping.setOrder(-1); // 关键:设置比默认更小的 order mapping.registerHandler("/custom/hello", new CustomHelloHandler()); return mapping; } }这里的setOrder(-1)是关键。如果不设置,默认 order 是Ordered.LOWEST_PRECEDENCE,会排在所有内置映射后面,基本轮不到它。设置成 -1 让它排在RequestMappingHandlerMapping前面,这样特定路径的请求会先被它拦截。
注意:order 设置得太小会拦截所有请求,包括那些本该由注解式 Controller 处理的。所以自定义映射的路径要尽量精确,避免用
/**这种宽泛的模式。
5.4 处理器的返回值与执行链
getHandlerInternal返回的对象,最终会被包装进HandlerExecutionChain。如果返回的是一个普通的 Java 对象,Spring 需要知道怎么执行它。这时候通常需要配合一个HandlerAdapter来使用。
如果你返回的是HandlerMethod,那RequestMappingHandlerAdapter就能处理。如果返回的是自定义对象,你得自己写一个对应的HandlerAdapter,告诉 Spring 怎么调用它、怎么处理返回值。
public class CustomHelloHandler { public void handle(HttpServletRequest request, HttpServletResponse response) throws IOException { response.getWriter().write("hello from custom handler"); } }对应的 HandlerAdapter 需要实现supports和handle方法。这部分内容展开又是一大篇,这里先点到为止,核心是让你知道:HandlerMapping 和 HandlerAdapter 是配套的,前者负责“找”,后者负责“执行”。
6. 那些年我在路由上踩过的坑
6.1 拦截器不生效:可能是 HandlerMapping 没匹配上
有一次配置了一个拦截器,想拦截/api/**的所有请求,结果发现部分请求没被拦截。排查后发现,那些没被拦截的请求走的是SimpleUrlHandlerMapping(静态资源处理),而拦截器是配在RequestMappingHandlerMapping上的。
这个坑的本质是:拦截器是绑定在 HandlerMapping 上的,不同的 HandlerMapping 有各自的拦截器列表。你在 WebMvcConfigurer 里加的拦截器,默认只加到RequestMappingHandlerMapping上。如果请求被其他 HandlerMapping 处理了,拦截器自然不生效。
解决办法是要么把静态资源也纳入注解式路由的管理,要么给对应的 HandlerMapping 单独配置拦截器。
6.2 路径匹配的优先级陷阱
前面提到过,/api/user和/api/**同时存在时,匹配结果可能和直觉不符。更隐蔽的一种情况是:两个不同 Controller 里的方法,路径模式有重叠,但因为你没意识到排序规则,导致请求被路由到了错误的方法。
排查这类问题的技巧是:打开 Spring MVC 的 debug 日志,它会打印出所有注册的映射以及匹配过程。看到实际的匹配顺序,问题就一目了然了。
logging.level.org.springframework.web.servlet.handler=DEBUG logging.level.org.springframework.web.servlet.mvc.method=DEBUG6.3 自定义 HandlerMapping 返回 null 的连锁反应
自定义 HandlerMapping 如果返回 null,Spring 会继续尝试下一个 HandlerMapping。这本身没问题,但如果你在getHandlerInternal里抛了异常,整个请求就会直接失败,后面的 HandlerMapping 根本没机会尝试。
所以自定义实现里要做好异常处理,拿不准的情况下宁可返回 null,让后续的映射有机会处理,也不要让异常冒泡出去。
6.4 动态注册映射的并发问题
有些场景需要在运行时动态添加或删除映射,比如多租户系统里租户配置变化时。这时候要注意MappingRegistry的并发控制。前面看到它用了读写锁,但如果你直接操作内部的 Map,绕过了锁,就可能出现并发问题。
正确的做法是通过registerMapping和unregisterMapping这些官方提供的方法来操作,它们内部会处理好锁和索引更新。
| 坑点 | 现象 | 根因 | 解决思路 |
|---|---|---|---|
| 拦截器不生效 | 部分请求绕过拦截器 | 拦截器绑定在特定 HandlerMapping 上 | 确认请求由哪个映射处理,补配拦截器 |
| 路径优先级异常 | 请求路由到非预期方法 | 映射排序规则与直觉不符 | 开 debug 日志看匹配顺序 |
| 自定义映射失效 | 自定义逻辑从不执行 | order 太大,被内置映射抢先 | 设置更小的 order |
| 动态注册并发异常 | 偶发的映射丢失或错乱 | 绕过锁直接操作内部结构 | 使用官方注册方法 |
7. 从源码里能学到的设计思路
拆完HandlerMapping这条链路,有几个设计思路我觉得值得单独拎出来说。
第一个是“单一职责 + 模板方法”的组合。接口只定义一件事,抽象类固定流程,子类只填核心逻辑。这种结构让扩展变得非常可控,你不用担心改坏框架的流程,只需要关注自己的那部分。
第二个是“多级索引”的思想。MappingRegistry不是简单地把所有映射塞进一个 List 里遍历,而是建了多个索引来加速查找。这种思路在你写任何需要频繁查找的数据结构时都用得上。
第三个是“顺序即优先级”的显式化。通过 order 属性把优先级这件事摆到明面上,而不是藏在代码逻辑里。这样调试的时候,你只要看 order 值就能推断出匹配顺序,大大降低了排查成本。
第四个是“返回值承载上下文”的做法。HandlerExecutionChain不只是返回一个处理器,还把拦截器、CORS 配置都打包进去了。这种设计减少了方法调用次数,也让调用方拿到的是一个完整的执行上下文,而不是零散的信息。
这些思路不限于 Spring MVC,在你设计自己的框架或中间件时,都可以借鉴。尤其是当你需要处理“多种策略、按优先级选择”这类问题时,HandlerMapping 的模式几乎可以直接套用。
最后分享一个我自己的习惯:每次遇到路由相关的问题,第一反应不是去改代码,而是先把 debug 日志打开,看清楚请求到底经过了哪些 HandlerMapping、最终被哪个映射处理了。大部分时候,日志看完问题就清楚了一半。源码不用背,但这条链路的走向心里要有数,排查起来才不会像无头苍蝇。