资讯动态

Spring Boot配置context-path后拦截器路径匹配失效?根因与解法

发布时间:2026/10/9 7:21:47 来源:尧图企业网站定制
1. 事故现场加了context-path之后登录拦截器集体失守有一段时间我特别不爱接“顺手改配置”的需求——因为往往不是改配置本身出问题而是改完之后带出一串连环坑。上个月我又踩了这么一脚一个Spring Boot项目本来没有统一前缀接口都挂在根路径下为了配合网关转发我在application.yml里加了一行server.servlet.context-path/api。服务重启后接口都通了我寻思着路径变干净了顺手把登录拦截器的匹配路径也改成/api/**吧结果所有受保护接口全部裸奔——未登录状态下直接返回了业务数据。第一反应是拦截器没加载。但编译、重启、看日志addInterceptors确实执行了拦截器实例也注册进去了。我一度怀疑是不是项目里有两套WebMvc配置互相覆盖查了一通也没发现。最后临时把addPathPatterns(/api/**)改回/**一切恢复正常。问题显然出在路径匹配上不是出在注册上。这个坑本身一句话就能讲清楚配置了context-path之后Spring MVC的拦截器路径匹配是基于“剥掉context-path之后的应用内路径”而不是浏览器地址栏里的完整URL。但知道结论和能从容排查是两回事这篇文章把我从现象到根因的完整过程写出来包含我后来验证过的最小复现和几个同源坑希望能帮人少走弯路。1.1 为什么第一反应都以为是“拦截器没注册”Spring Boot的拦截器配置太“温柔”了配错了不加前缀不报错不打日志。路径匹配不到时行为就和没注册一模一样。如果你一开始写的是addPathPatterns(/api/**)且代码没有问题那你看到的表象就是拦截器的preHandle压根不执行。这个坑的迷惑性在于/api/**看起来无比正确。你刚给项目加了context-path/api浏览器访问的明明就是/api/xxx为什么这里写/api/**就匹配不上这就是“路径的展示形态”和“路径的匹配形态”之间的差异。浏览器看到的是带前缀的完整URL而拦截器匹配时看到的路径已经换了一副面孔。我第一次踩的时候在preHandle里打了日志结果日志一条都没出现顿时心里一凉以为自己的拦截器配置类压根没被加载。后来查下来配置类一切正常拦截器也确实注册了只是路径匹配永远失败。这种“注册成功但匹配失败”的状态比“没注册”更难发现因为你不会往路径基准的方向想。1.2 一个让人更迷惑的现象改成/**立刻有效当时我把匹配路径改成/**后登录拦截器立刻生效。这让第一反应更倾向于“哦那就是写法问题/**肯定没错”。但如果就此收手下次遇到类似场景还得重新猜。为什么不带前缀的/**能拦到带前缀的所有请求说明拦截器看到的匹配路径根本不含/api。再细想一下context-path的作用是让Tomcat把请求按照应用上下文分发给对应应用Spring MVC拿到手之后路径里的context-path已经被容器剥掉了。也就是说浏览器地址栏里的/api/user/list到了拦截器的匹配阶段实际参与比对的是/user/list。你写的规则是“匹配所有以/api开头的路径”可拦截器手里根本没有/api这个片段自然一条都匹配不上。这个现象一旦理解后面所有行为都说得通了。而且它不只影响拦截器还影响Spring Security的路径匹配、重定向拼接、静态资源排除、Swagger资源加载等。搞清楚这一点能帮你把一整类看似无关的bug串起来。2. 路径匹配的真实基准context-path在请求处理链里到底被谁剥掉2.1 从完整URL到Servlet路径三个request方法对照先拿一个具体请求举例。假设服务配置了server.servlet.context-path/api浏览器访问的是http://localhost:8080/api/user/list。request里有三个方法经常被混用它们的返回值分别如下request方法示例值含义getRequestURI()/api/user/list请求的完整URI包含context-pathgetContextPath()/api应用上下文前缀getServletPath()/user/listServlet映射匹配后的路径不含context-pathgetPathInfo()null使用/*映射时剩余的路径DispatcherServlet默认映射时通常为null只要把这三个值打出来很多路径问题就能秒懂前两个是完整URL的分量第三个才是Spring MVC真正做匹配时用的主体。这里有个前提DispatcherServlet默认映射到/所以getServletPath()返回的是减去context-path之后的完整应用路径在不同的容器或自定义Servlet映射下getServletPath()的形态可能有细微差异但getRequestURI()始终包含context-path这一点不会变。实际项目中很多人会在Controller里通过request.getRequestURI()去做权限判断然后发现拿到的路径永远带/api又和拦截器的匹配结果对不上。原因就在表格里这两个方法本来就不是同一个视角。2.2 拦截器匹配的lookupPathHandlerMapping的同一把尺子Spring MVC的路径匹配在HandlerMapping里完成。AbstractHandlerMapping.getHandler()会通过lookupPath拿到用于匹配的请求路径这个lookupPath通常由servletPath和pathInfo拼出来等于不含context-path的那部分。拦截器的addPathPatterns本质上是在给MappedInterceptor注册一组patterns当HandlerMapping做路径匹配时会拿这个lookupPath去和patterns逐一比对。也就是说拦截器用的是HandlerMapping同一把尺子而尺子的零点已经移除了context-path。用快递柜类比context-path就是快递柜上的柜号快递员Tomcat根据柜号把包裹投进对应的柜子应用。包裹进入柜子之后柜子里的整理员Spring MVC/拦截器看到的是包裹本身包裹上不会额外贴一遍柜号。你在整理规则里写“把所有柜号是/api的包裹都整理一遍”整理员当然办不到因为在他这个视角里根本不存在/api这个字段。2.3 PathPattern与AntPathMatcher通配符的差异会放大错觉另一个容易被忽略的是路径匹配器本身。Spring Boot 2.6之前Spring MVC默认用AntPathMatcher2.6开始默认切到了PathPatternParser。两者对*和**的解释有细微差别*只匹配一层路径段/api/*能匹配/api/user不能匹配/api/user/detail。**匹配零层或多层路径段/api/**能匹配/api/user也能匹配/api/user/detail。/**在两种匹配器里一般都能匹配根路径但/*不行。PathPattern对路径归一化、编码字符、尾部斜杠等处理得更严格格式错误时会直接抛出异常而不是悄悄不匹配。这些差异平时感知不强一旦你像我当时那样带着错误前缀去写pattern会进一步放大排查难度。比如你写/api/*本以为能匹配/api/user/list结果只能匹配/api/user这时候你可能会去怀疑是不是*意思不对而根本想不到问题出在/api这个前缀压根不该出现。所以踩坑时尽量不要上来就猜通配符先把“匹配基准”这条主线摸清楚。3. 完整排查链路从“拦截器没生效”到根因坐实3.1 第一轮排查排除拦截器根本没注册的可能路径匹配不生效和拦截器没注册外在表现完全一样所以排查的第一步是确认注册链路是通的。我按顺序做了这几件事确认Configuration类被扫描到WebMvcConfigurer实现被Spring加载。在addInterceptors方法入口打日志确认方法被执行。通过ApplicationContext.getBeansOfType检查拦截器实例是否存在。检查项目里有没有自定义的WebMvcConfigurationSupport这个东西一旦出现会接管Spring MVC的自动配置导致你自己写的WebMvcConfigurer部分失效。大部分情况下前三步都正常问题就锁定在“注册成功但匹配失败”。这里有个容易忽略的点如果项目里有人为了扩展WebMvcConfigurationSupport而继承它并且没调super方法那所有基于WebMvcConfigurer的配置都可能被架空。排查时不要只盯着自己的配置类全局搜一下有没有WebMvcConfigurationSupport的子类更稳妥。3.2 第二轮排查把三个路径值打出来问题当场就清楚了因为拦截器没匹配时不进preHandle我临时加了一个最高优先级的OncePerRequestFilter在过滤链里打印三个关键路径值。访问/api/user/list时控制台输出requestURI /api/user/list contextPath /api servletPath /user/list看到这里基本就明白了当前拦截器里的pattern是/api/**而Spring MVC要匹配的路径是/user/list当然失配。做个对照实验会更直观如果不配context-path同样的请求servletPath就是/api/user/list那时写/api/**一点问题都没有。Bean public OncePerRequestFilter pathLoggingFilter() { return new OncePerRequestFilter() { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { System.out.println(requestURI request.getRequestURI()); System.out.println(contextPath request.getContextPath()); System.out.println(servletPath request.getServletPath()); filterChain.doFilter(request, response); } }; }这个临时Filter用完就删但排查价值极高。以后任何一个“路径对不上”的问题我都是先打这三个值再动手能省掉大量对着通配符瞎猜的时间。3.3 第三轮排查从源码确认lookupPath的来龙去脉为了把这个结论坐实我在AbstractHandlerMapping.getHandler()里打了断点。执行流程大概是这样的通过ServletRequestPathUtils解析并缓存当前请求的lookupPath。遍历mappedInterceptors调用mappedInterceptor.matches(request, handler)。matches内部用已经解析好的PathPattern去匹配lookupPath。我盯住的那个MappedInterceptor对象里存着的pattern就是/api/**而request的lookupPath是/user/list最终PathPattern.matches返回false。这就在源码层面把“路径基准”坐实了不是通配符写错不是拦截器没注册而是匹配所依据的路径本身就不包含/api。这个排查结论后来直接写进了单元测试用MockMvc发送带contextPath的请求校验拦截器是否被调用。以后任何人再改这个配置测试都能自动兜底比人工验证可靠得多。3.4 用最小Demo把问题钉死如果你不想看源码也可以直接复现。新建一个Spring Boot项目在application.yml里加一行server: servlet: context-path: /api写一个preHandle里打印日志的拦截器注册时写addPathPatterns(/api/**)。启动后访问任意接口拦截器不打印。把pattern改成/**日志立刻出来了。整个复现不到十分钟而且不依赖任何业务代码。我后来把这个最小Demo留在本地专门用来测试各种路径匹配方案。后面验证修复方案是否靠谱时先在小项目里过一遍再回大项目改比直接在生产代码里反复重启高效得多。4. 三种可用解法按场景选型4.1 方案一统一/**用excludePathPatterns做减法推荐大部分项目的拦截器目的就是“除了公开资源其他都拦”。配置了context-path之后强烈建议直接写addPathPatterns(/**)需要放行的路径用excludePathPatternsConfiguration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor loginInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /static/**, /error); } }注意这里的排除路径同样不含context-path登录接口实际是/api/login排除时写/login写/api/login反而放行不了。为什么推荐这个方案因为context-path已经是全局前缀应用内路径不再需要知道/api。写/**比写/api/**更接近“整体上拦”的语义以后context-path不管改成/v1还是/anything拦截器都不用跟着改。用减法思路管理放行路径也比用加法去枚举“都要拦哪些模块”更不容易漏。4.2 方案二按模块拦截时前缀要写到context-path之内如果你的确只想拦住某个模块比如/api/user/**或者/api/admin/**做权限校验有context-path时应该写的是/user/**或/admin/**而不是/api/user/**。这里有个记忆方法context-path负责把“外部前缀”从路径里剥掉剩下的应用内路径才是拦截器规则的作用对象。所以你在addPathPatterns里写的前缀永远以context-path之后那一段为起点。举个例子你配置了context-path/apiController映射是/user/list前端访问的是/api/user/list。拦截器里想只拦user模块就写/user/**因为框架匹配用的路径是/user/list。这个方案适合那些本身就想精细区分模块、但又不想引入复杂的自定义注解判断的团队。4.3 方案三放弃context-path把路径前缀交给Spring MVC如果你被context-path相关的坑搞得烦了且项目由你说了算干脆别用server.servlet.context-path改在Controller层统一加前缀RestController RequestMapping(/api/product) public class ProductController { // 业务方法 }或者用Spring Framework 5.3之后PathMatchConfigurer提供的addPathPrefix给某一类Controller统一加前缀。好处是路径前缀变成应用内路径的一部分拦截器、Spring Security等都按直觉写/api/**即可不用再关心容器剥路径的问题。代价是Controller代码里到处带/api如果将来部署时想动态调整前缀context-path可以在配置文件里改应用层前缀则要改代码或引入额外的路由配置。如果项目规模不大这个取舍通常可以接受。我的建议是团队里如果是新项目、路径规范还没定死优先考虑这个方案老项目已经在用context-path且各方依赖它的还是老老实实按方案一调整拦截器写法。4.4 验证配置是否正确盯住servletPath别只看接口通不通无论选哪个方案最后验证时建议按三个层次来看接口能通说明路由没问题。受保护接口未登录时被拦截说明正向拦截器生效。登录页或公开接口能正常访问说明excludePathPatterns生效。最好再加一层自动化用MockMvc模拟带contextPath的请求校验拦截器是否被调用。很多项目里“接口通”不等于“拦截器配对了”两者要分开看。比如我在排查时就见过一种情况拦截器路径写错了但接口恰好用的是公开数据日志上看不出任何问题直到上线后才发现等于没拦。所以验证时一定要找一个本应被拦截的接口用未登录态去打一次看它是否真的返回401或跳转。5. 同一个根源带出来的兄弟坑重定向、静态资源与安全框架5.1 sendRedirect丢context-path拦截器的坑解决后同一天又发现登录成功后要跳转的地址少了前缀。原因和前面完全同源response.sendRedirect(/login)给浏览器回一个Location头浏览器会按根路径解析不会自动带上/api。正确写法是取contextPath拼接response.sendRedirect(request.getContextPath() /login);只要项目里配置了context-path凡是手动拼URL的地方都要考虑这个前缀Filter里的转发和重定向尤其常见。我当时在登录成功后的Filter里跳转返回的Location是/login浏览器访问的却是http://host/login直接404。这个坑和拦截器路径匹配是同一个根源但如果你没把两者联系起来可能会以为又是另一套配置问题。5.2 静态资源与Swagger排除路径同样不含前缀如果你用WebMvcConfigurer放行静态资源排除路径写的是/static/**而不是/api/static/**。同理/error、/favicon.ico这些特殊路径也不要加/api。Swagger的UI地址通常由框架自己拼context-path但如果你手动配置了Doc的路径或者做了网关前缀映射会出现页面能打开但静态资源加载失败的情况。排查方式一样打开浏览器开发者工具看请求落到后端时servletPath是什么再对照自己的排除规则。当时我排查一个Swagger样式丢失问题发现浏览器请求/api/swagger-ui/xxx后端servletPath是/swagger-ui/xxx而配置里排除的却是/api/swagger-ui/**自然不生效。5.3 Spring Security的匹配基准是一样的Spring Security里的路径匹配同样基于应用内路径。旧版本的antMatchers(/api/**)在新版本里一样不好使5.8之后官方推荐requestMatchers(/api/**)但不管用哪个匹配基准仍然不含context-path。如果你同时用了Spring Security和拦截器且两边都有路径规则记住这两边的pattern视角是同一个——都不带context-path。否则容易出现“接口被Security拦了但Interceptor没拦”这种一个生效一个不生效的诡异组合。我排查过的一个案例是Security配置里放行了/api/login但实际登录接口的servletPath是/login导致登录接口被Security拦截和拦截器路径写错的原理一模一样。5.4 网关转发导致的“双重前缀”最后说一个和context-path搭配更麻烦的场景前面有Nginx或API网关。不少人以为“前端访问/api/xxx网关转给后端8080后端配了context-path/api于是后端收到/api/xxx剥掉后是/xxx完美”。但实际网关的转发规则可能已经rewrite过路径也可能原样转发如果后端在Controller上又习惯性地加了RequestMapping(/api)就会出现双重前缀最终访问路径变成/api/api/xxx。我的建议是接入层、网关层、后端应用层前缀只由一层负责。要么网关负责/api后端context-path留空要么后端用context-path网关只做端口转发不做路径改写。判断时先在后端访问日志里确认到达的requestURI和servletPath不要凭浏览器地址猜。加了多层路径处理后每个中间层都可能改写URL只有后端日志里的值才是最终真相。最后分享一个我自己的排查习惯凡是遇到“路径匹配不生效”我第一件事不是改pattern而是先把request.getRequestURI()、request.getContextPath()、request.getServletPath()三个值打出来确定当前处理链里路径的“真实形态”再去写匹配规则。这个习惯帮我避开了很多次对着通配符瞎猜的情况。context-path本身不复杂复杂的是它把路径的“展示形态”和“匹配形态”分成了两层只要记住这两层各是什么这个坑就不会再绊住你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑