HandlerInterceptor 和 WebFilter:两套 HTTP 请求拦截
作者:苏渡苇
项目地址 :https://github.com/iweidujiang/spring-insight(感谢 Star !)
本文要干什么
上一篇讲的是 装哪一套配置 :同一份 Starter,MVC 服务导入拦截器,Gateway 不碰 WebMvcConfigurer。
配置装上之后,监测工具真正要干的活才开始------有请求进来时,记下这条 HTTP 调用:路径、耗时、成功还是失败。
Spring 里拦 HTTP 入口,没有"一个接口打天下":
- 普通 Web 服务(
spring-boot-starter-web)走 Servlet / Spring MVC ,入口钩子是HandlerInterceptor - Gateway(以及纯 WebFlux)走 Reactor ,入口钩子是
WebFilter
所以这篇的知识点就这两个类。目的是讲清楚:
它们各自拦在请求的哪一步、Span 存在哪、为啥 Gateway 不能复用 MVC 那套
ThreadLocal调用栈。
一、一次请求,两道关
先看一下调用链:

两边记的东西差不多(方法、路径、状态码、耗时),但钩子不是同一个,上下文也不是同一个。
如果硬要共用一套,Gateway 上轻则 Span 对不上,重则压根装不进去。
Insight 里对应两个类:
| Web 栈 | 钩子 | 代码 |
|---|---|---|
| Spring MVC | HandlerInterceptor |
HttpRequestInterceptor |
| WebFlux / Gateway | WebFilter |
ReactiveInsightWebFilter |
下面分开看。
二、MVC 这边:HandlerInterceptor
源码位置:
io.github.iweidujiang.springinsight.agent.instrumentation.HttpRequestInterceptor。
Spring MVC 处理请求,大致是:
text
请求进 DispatcherServlet
→ 一串 HandlerInterceptor.preHandle
→ Controller
→ 一串 afterCompletion(有异常也会走)
Insight 要的就是两头各钉一颗钉子:进来时开 Span,出去时关 Span 并上报。
java
@Override
public boolean preHandle(HttpServletRequest request, ...) {
TraceSpan span = TraceContext.startSpan(method + " " + uri);
span.setSpanKind("SERVER");
span.setComponent("SpringMVC");
request.setAttribute("X-Trace-Span", span);
return true; // 放行
}
@Override
public void afterCompletion(..., Exception ex) {
// 根据异常 / 4xx 5xx 给 Span 打失败标记
TraceContext.endSpan(errorCode, errorMessage);
spanReportingListener.reportSpan(span);
TraceContext.clear(); // 线程可能被池子复用,必须清
}
几个值得深思的点:
1. 用 afterCompletion,不用 postHandle
postHandle 在视图渲染前,而且 Controller 抛异常时可能走不到。afterCompletion 是「这次请求收工」,成功失败都会到。监测要的是完整耗时和错误,钉在这里更合适。
2. Span 放了两个地方
TraceContext(ThreadLocal 栈):同一线程里后面的 Feign、DB 切面能拿到当前 Span,继续挂子节点request.setAttribute:结束时从请求上把 Span 捞回来,避免中间某层把栈搞乱找不到人
之前专门讲过这个 ThreadLocal + Deque 调用栈------用 ThreadLocal + Deque 打造一个"线程专属的调用栈" ------ Spring Insight 的上下文管理术
MVC 里能这么干,前提是:一个请求在业务处理期间,大体还在同一条线程上。 Tomcat 工作线程接请求、跑 Controller、跑完再还回线程池------这个假设成立。
3. 结束一定要 clear()
线程池会把线程拿去接下一个请求。不 remove ThreadLocal,下个请求可能看到上个请求的 traceId。这不是小毛病,是串单。

注册方式是实现 WebMvcConfigurer,addInterceptors 里带上排除路径:/actuator/**、/api/v1/** 这些别再追踪一遍,否则会把自己的上报接口也记成一条业务调用。
三、Gateway 这边:WebFilter
此部分的源码在:
io.github.iweidujiang.springinsight.agent.instrumentation.ReactiveInsightWebFilter
WebFlux 没有 DispatcherServlet,也没有 HandlerInterceptor。请求是一条 发布/订阅流水线 ,类型是 Mono / Flux。
入口钩子是 WebFilter:
java
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
TraceSpan span = new TraceSpan();
span.setComponent("SpringWebFlux");
exchange.getAttributes().put(SPAN_EXCHANGE_ATTR, span);
return chain.filter(exchange)
.doOnError(ex -> finalizeSpan(exchange, ex))
.doFinally(signal -> {
if (signal != SignalType.ON_ERROR) {
finalizeSpan(exchange, null);
}
});
}
和拦截器对照着看,差别不是「API 名字换了」,是执行模型换了。
1. 拦的是整条链,不是 pre/post 两个回调
你必须 return chain.filter(exchange),把后面的网关路由、下游转发接上。Span 的结束不能写在 filter 方法的末尾------那时候下游还没跑完,方法已经返回一个「以后才会完成的 Mono」。
结束动作挂在 doOnError / doFinally 上:流水线成功、失败、取消,才关 Span。这才是 WebFlux 里的 afterCompletion。
2. Span 不能放 ThreadLocal
Gateway 处理一个请求,线程会切。接请求的是 event loop 上的某条线程,等下游 HTTP 回来可能是另一条。TraceContext 绑在线程上,一切就丢。
所以 Insight 的 WebFilter 不用 TraceContext.startSpan(),而是:
java
exchange.getAttributes().put(SPAN_EXCHANGE_ATTR, span);
ServerWebExchange 跟着这次请求走,不跟线程走,结束时从 attribute 里取出 Span:避免线程切换导致上下文丢失。

要是这里还去 TraceContext.startSpan(),线程 C 的 ThreadLocal 是空的,上报的要么是残的,要么是别人的。
3. 过滤器要靠前
java
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
希望尽量包住整次网关处理。排太靠后,前面已经耗掉的时间就记不到了。
排除路径在 Filter 里自己用 AntPathMatcher 比。MVC 那边是注册拦截器时 excludePathPatterns;WebFilter 没有那套注册 API,只能自己判断。
四、类比一下
HandlerInterceptor |
WebFilter |
|
|---|---|---|
| 属于谁 | Spring MVC | WebFlux(Gateway 也在这) |
| 生命周期 | preHandle / afterCompletion |
filter 返回 Mono,结束靠 doFinally |
| Span 存在哪 | ThreadLocal 栈 + request attribute | ServerWebExchange 的 attributes |
| 为啥能 / 不能用 ThreadLocal | 业务处理大体在同一线程 | 请求会跨线程 |
| 同一请求里的子调用 | Feign / DB 切面能从 TraceContext 取父 Span |
入口 Span 不进 ThreadLocal,下游服务自己再埋 |
最后一行是现状,也说开:Gateway 上的 Filter 目前记的是网关自己的入口 Span。后面 order 服务里的拦截器会再开一个根 Span。
跨进程把同一个 traceId 传过去,Insight 还没做(没写 traceparent 这类头)。拓扑边主要靠 Feign 上的 remoteService 。
所以现在你在控制台上看到的,更像「每个服务各自记自己的入口」,再用调用关系把点连起来------不是 Zipkin 那种一条 traceId 穿全场。先能看,以后再补传播。
五、自己写监测 / 日志拦截时可以记住的
- 先问宿主是 Servlet 还是 WebFlux,再选钩子。 不要指望一个
Filter在 Gateway 上跟 MVC 里表现一样------Gateway 用的根本不是 Servlet Filter 那条路。 - ThreadLocal 跟「一条线程跑完一个请求」绑定。 异步、WebFlux、线程池切走,上下文就要换地方存,比如 request / exchange attribute。
- 开始和结束必须成对,结束要挂在「请求真正完事」的回调上。 MVC 用
afterCompletion,WebFlux 用doFinally,别在异步方法 return 的那一行就endSpan。 - 线程池场景记得
ThreadLocal.remove。 拦截器里那句TraceContext.clear()不是洁癖。
六、小结
| 你以为的 | 实际是 |
|---|---|
| 拦 HTTP 用一个拦截器就够了 | MVC 和 WebFlux 是两套 API |
| Gateway 把 MVC 拦截器 exclusion 掉就行 | 要换成 WebFilter,否则没入口 Span |
| Span 放 ThreadLocal 哪儿都能用 | WebFlux 里会丢,得跟请求对象走 |
filter() 方法结束 = 请求结束 |
那只是返回了 Mono,下游还在路上 |
🌟 最后:欢迎围观 Spring Insight
如果你对「轻量监测 / Spring Boot Starter / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。