HandlerInterceptor 和 WebFilter:两套 HTTP 请求拦截

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。这不是小毛病,是串单。

注册方式是实现 WebMvcConfigureraddInterceptors 里带上排除路径:/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 穿全场。先能看,以后再补传播。

五、自己写监测 / 日志拦截时可以记住的

  1. 先问宿主是 Servlet 还是 WebFlux,再选钩子。 不要指望一个 Filter 在 Gateway 上跟 MVC 里表现一样------Gateway 用的根本不是 Servlet Filter 那条路。
  2. ThreadLocal 跟「一条线程跑完一个请求」绑定。 异步、WebFlux、线程池切走,上下文就要换地方存,比如 request / exchange attribute。
  3. 开始和结束必须成对,结束要挂在「请求真正完事」的回调上。 MVC 用 afterCompletion,WebFlux 用 doFinally,别在异步方法 return 的那一行就 endSpan
  4. 线程池场景记得 ThreadLocal.remove 拦截器里那句 TraceContext.clear() 不是洁癖。

六、小结

你以为的 实际是
拦 HTTP 用一个拦截器就够了 MVC 和 WebFlux 是两套 API
Gateway 把 MVC 拦截器 exclusion 掉就行 要换成 WebFilter,否则没入口 Span
Span 放 ThreadLocal 哪儿都能用 WebFlux 里会丢,得跟请求对象走
filter() 方法结束 = 请求结束 那只是返回了 Mono,下游还在路上

🌟 最后:欢迎围观 Spring Insight

如果你对「轻量监测 / Spring Boot Starter / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:

🔗 GitHubhttps://github.com/iweidujiang/spring-insight

当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。

你的 Star 是对我最大的鼓励。

相关推荐
随遇而安zx39 分钟前
SpringCloud---安全(Security / OAuth2 / JWT)设计思想与深度解析
安全·spring cloud
骇客野人1 小时前
SpringBoot+SpringCloud高并发系统设计、搭建与落地实施方案
java·spring boot·spring cloud
好评1242 小时前
【Linux】应用层协议HTTP
linux·运维·http
程序员贺加贝2 小时前
报表大-IN-优化-一条-product_profile-超大-IN-SQL-背后的报表任务治理
java·spring boot·sql·性能优化·架构
霸道流氓气质2 小时前
Spring AI多模型路由与动态切换
java·后端·spring
艺杯羹2 小时前
全栈信创落地实录:基于银河麒麟V10与达梦数据库DM8的SpringBoot工业级适配指南
java·数据库·spring boot·后端·spring
随遇而安zx3 小时前
SpringCloud---Gateway vs Netflix Zuul 网关对比深度解析
spring·spring cloud·gateway
鲨鱼辣钊6 小时前
【FastAPI筑基-Day19】APScheduler定时任务全实战|自动执行、动态启停、后台常驻
java·spring·fastapi
学长毕业设计10 小时前
基于SpringBoot的公益基金管理系统(源码+文档+讲解视频)
java·spring boot·后端