HandlerInterceptor 和 WebFilter:两套 HTTP 请求拦截

作者:苏渡苇

项目地址github.com/iweidujiang...(感谢 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 / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:

🔗 GitHubgithub.com/iweidujiang...

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

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

相关推荐
云上小朱1 小时前
部署安全的pg数据库,搭配pgadmin实现web界面管理
后端
爱勇宝1 小时前
初创公司的“自己人”,到底能当多久?
前端·后端·程序员
掘金者阿豪1 小时前
Mac mini Intel 报错 DNS_PROBE_FINISHED_BAD_CONFIG 完整排障指南
后端
大模型丫丫2 小时前
如何提升单体 Spring Boot 应用的并发数?
java·spring boot·后端
Terra.K2 小时前
ThreadLocal解决一个线程里面跨层传递问题
java·开发语言·后端
Lost of 程序猿2 小时前
ASP.NET Core API 版本管理与契约演进:船岸版本碎片化下的兼容性工程
后端·asp.net
吃饱了得干活2 小时前
Redis 从单机到集群:持久化、主从复制、哨兵与集群完全指南
redis·后端
老孙讲技术2 小时前
【监控开发】把车间未戴帽和烟火报警接进安监值班群:workwearDetect 对图与 setMessageCallback 订 smokeAlarm
后端·物联网·音视频开发
计算机毕设定制辅导-无忧学长3 小时前
基于Spring Boot的玄幻小说个性化推荐平台设计与实现
java·vue.js·spring boot·后端·mysql·推荐算法