Filter、Interceptor 与请求上下文
Filter 可以包住 Servlet 的输入输出,Interceptor 可以在选中 handler 后检查方法信息。请求一旦转为异步处理,还会经过工作线程和后续 ASYNC 分派。横切逻辑放在哪一层,决定它看得见什么、什么时候执行,以及由谁清理。
以请求标识为例:HTTP 请求需要一个可复用的标识,日志系统需要把它放到当前线程,后台任务则需要显式取得一份副本。这是三个不同的生命周期。
根据可见对象选择扩展位置
Servlet 链与 MVC 链
Servlet 容器选择分派
└── 匹配的 Filter A
└── 匹配的 Filter B
└── DispatcherServlet
├── HandlerMapping:选出 handler
├── Interceptor.preHandle
├── HandlerAdapter:参数、调用、返回值
└── Interceptor 的相应后置回调Filter 接收 ServletRequest、ServletResponse 和 FilterChain。调用 chain.doFilter 才会继续下游;返回以后,可以做当前分派的收尾。它既可用于 DispatcherServlet,也可以根据注册匹配其他 Servlet。
Interceptor 接收已经选出的 handler,适合读取 HandlerMethod 的类型、方法和注解。它不能覆盖未进入 MVC 的请求,也不能假定所有 handler 都是 Controller:静态资源或其他处理器有不同类型。
| 需要处理的工作 | 常用位置 | 原因 |
|---|---|---|
| 字符编码、请求包装、低层日志关联 | Filter | 在 MVC 读取参数和主体前处理 |
| 统一认证和授权 | Spring Security 对应扩展 | 保留完整认证协议及安全过滤链 |
| 检查 handler 注解或方法元数据 | Interceptor | 此时已经找到 handler |
| JSON 写出前增加头部或调整限定类型 | ResponseBodyAdvice | 已选择输出转换器,可接触响应主体 |
| 事务、业务权限和领域规则 | 服务层及相应安全机制 | 同一业务还可能被消息或任务调用 |
| 工作线程的日志上下文 | Executor 的任务装饰器 | 切换线程时捕获和恢复上下文 |
拦截器可做方法级附加处理,但不适合独自承担完整安全体系。路径匹配、静态资源、错误和异步分派都可能造成覆盖差异。Spring 官方的 Interception建议采用专门安全框架,并尽早在 Servlet 过滤链中处理安全逻辑。
回程不是重新执行一遍前置
两个拦截器 A、B 成功通过 preHandle 后,正常 postHandle 与 afterCompletion 逆序执行。B 返回 false 时,仅之前已成功的 A 会收到 afterCompletion;B 要自行清理拒绝前已经分配的资源。
@ResponseBody 与 ResponseEntity 的写出发生在 HandlerAdapter 内部,postHandle 可能已来不及修改响应。视图返回则先经过 postHandle,再渲染 View。这些位置关系在 DispatcherServlet 请求总链有真实响应对照。
Filter 的 finally 表示当前调用栈离开,不必然表示整个异步请求结束。把业务总耗时记在第一次 REQUEST 分派的 finally,会漏掉之后的等待和执行时间。同步调用耗时、工作任务耗时和完整异步请求耗时应分别选择观察点。
注册规则与请求标识处理
一次注册,明确 dispatcher type
@Bean
FilterRegistrationBean<RequestContextFilter> labRequestContextRegistration() {
var registration = new FilterRegistrationBean<>(
new RequestContextFilter(observations));
registration.setOrder(0);
registration.setDispatcherTypes(EnumSet.of(
DispatcherType.REQUEST,
DispatcherType.ASYNC,
DispatcherType.ERROR));
registration.setAsyncSupported(true);
registration.addUrlPatterns("/*");
return registration;
}这是实验工程的注册方式。Filter 实例由注册 Bean 创建,没有再加 @Component,也没有在别处重复声明同一实例的容器注册。
Boot 会自动注册适用的 Filter Bean。若既使用自动发现,又手工创建另一个实例进行注册,就可能执行两次。接入 Spring Security 等独立过滤链时,还要区分“作为 Servlet Filter 注册”与“加入框架内部过滤链”;需要禁用其中一个注册时使用明确配置,不能靠一次性标记碰巧挡住重复工作。Boot Filter 注册说明了自动注册及顺序定制。
dispatcher type 描述当前分派的原因。REQUEST 是初次请求,ASYNC 用于异步恢复,ERROR 用于容器错误处理;FORWARD 与 INCLUDE 则服务于内部转发或包含。注册里没有某种类型,Filter 就不会因该类型被调用。Filter 链中的异步支持也必须满足 Servlet 的要求,单独给一个 Controller 返回 Callable 不能补齐所有注册配置。Servlet 6.1定义了这些分派语义。
OncePerRequestFilter 的“一次”有条件
OncePerRequestFilter 使用请求属性标记已过滤状态,避免同一执行阶段不必要的重复。它默认跳过后续 ASYNC 和 ERROR 分派;实验显式覆盖这两个判断:
@Override
protected boolean shouldNotFilterAsyncDispatch() {
return false;
}
@Override
protected boolean shouldNotFilterErrorDispatch() {
return false;
}同时还要把 Filter 注册到对应 dispatcher type,二者缺一不可。这样同一 HTTP 请求在 REQUEST 和后续 ASYNC 分派会分别建立线程上下文。
“一次”不能理解为无论内部如何转发、异步或报错,总共只有一个回调。嵌套 ERROR 分派还有专门的 doFilterNestedErrorDispatch 处理点,容器执行方式会影响调用栈。需要为错误分派重新包装 request/response 时,应结合这个入口检查,而不是只覆盖 doFilterInternal。OncePerRequestFilter API列出了这些条件。
请求属性保存身份,MDC 服务于当前线程
实验从 X-Request-Id 接收可选标识,只允许 1–64 个字母、数字、下划线或短横线;不符合规则时生成 UUID,并保存到 request attribute。后续分派复用该属性,不重新从头部生成另一个值。
String id = (String) request.getAttribute(Observations.ID);
if (id == null) {
String supplied = request.getHeader("X-Request-Id");
id = supplied != null && supplied.matches("[A-Za-z0-9_-]{1,64}")
? supplied : UUID.randomUUID().toString();
request.setAttribute(Observations.ID, id);
}客户端提供的标识仅用于关联,不能证明用户身份或权限。长度和字符限制有助于控制日志体积及格式;仍应通过结构化日志记录,不拼接成 SQL、命令、存储路径或任意响应头名称。
进入当前分派时,把 id 放入 MDC;退出时恢复原来的值:
String previous = MDC.get("requestId");
MDC.put("requestId", id);
try {
chain.doFilter(request, response);
} finally {
if (previous == null) {
MDC.remove("requestId");
} else {
MDC.put("requestId", previous);
}
}只调用 remove 会丢失上层已有的值;直接 clear 则会一并移除其他 MDC 字段。这里仅负责 requestId,所以只恢复这一项。MDC API 及底层实现关系见 SLF4J MDC。
不要把完整请求对象塞进 ThreadLocal 长期保存。请求结束后对象可能被容器回收,后台任务继续读取会跨越它的有效期;线程池长期持有引用还会延长敏感信息和大对象的存活时间。
异步切换时复制什么、清理什么
请求线程和工作线程分别结束
图中的线程可以交错执行。初次分派返回后,后台任务仍可能继续;工作任务结束后,后续分派也可能尚未写完响应。清理只针对自己安装的数据,不等待某个“全局最后一步”来清空所有线程。
ThreadLocal 按线程保存值。向已有线程池提交任务时,普通 ThreadLocal 或 MDC 不会因此自动获得提交线程的当前值。InheritableThreadLocal 的复制时机又与线程创建有关,同样不适合把线程池每次任务的身份传播寄托在继承上。
在提交时捕获,在执行时安装
实验的 MdcTaskDecorator 接收待执行任务,立即取得提交线程的 MDC 快照;装饰后的 Runnable 真正执行时,再保存工作线程的旧值。
public Runnable decorate(Runnable task) {
Map<String, String> captured = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> before = MDC.getCopyOfContextMap();
try {
if (captured == null) MDC.clear();
else MDC.setContextMap(captured);
task.run();
} finally {
if (before == null) MDC.clear();
else MDC.setContextMap(before);
}
};
}捕获一份 Map 副本,可以避免提交后修改当前线程 MDC 影响已经排队的任务。finally 同时覆盖成功和异常路径。这里装饰的是完整 MDC,因此恢复的也是完整旧 Map;与上一节只修改 requestId 的 Filter 不同。
TaskDecorator 是线程池任务的执行包装,不会自动改变任意 Executor。MVC 配置的执行器、自己创建的 Executor、@Async、调度任务以及 CompletableFuture 使用的执行位置要分别确认。提交后的异常如何从 Future 取回,也由执行器 API 决定。TaskDecorator API说明了包装与异常观察的限制。
有界执行器与传播范围
工程为 MVC Callable 和 StreamingResponseBody 配置 ThreadPoolTaskExecutor:核心线程 2、最大线程 4、队列 8,并安装上述装饰器。数量只用于小型实验。线程数、排队长度、超时和拒绝方式,应根据工作类型及资源预算决定。
TaskDecorator 只复制日志上下文。数据库事务、认证上下文、Locale 或其他 ThreadLocal 不会因此一同变为跨线程共享状态。事务尤其不能通过复制一个引用跨线程使用同一连接;工作任务应有自己的明确事务范围。
若使用 Micrometer Context Propagation 等设施,可为多种上下文统一捕获和恢复,但仍需要确认实际注册的 accessor、执行器和框架支持。MVC 的异步与上下文说明见 Asynchronous Requests。
请求标识也不是完整分布式 Trace。跨服务 traceparent、tracestate 的解析、采样和传播应遵守 W3C Trace Context,让相应观测组件处理。不要把任意 X-Request-Id 字符串拼成 traceparent。
运行分派与线程复用实验
服务和检查入口
下载 MVC 实验工程,按Linux 准备步骤解包、编译并启动 mvc09-binding。普通用户运行 Docker;应用 UID 为 10001,端口为 127.0.0.1:18091。观察端点仅保存有界的实验事件。
BASE=http://127.0.0.1:18091
curl -q --noproxy '*' --fail-with-body --max-time 5 -i \
-H 'X-Request-Id: context-01' "$BASE/chain/async"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/lab/events/context-01"响应头的 X-Request-Id 为 context-01,主体含 status=READY。事件中应出现:
filter.enter:REQUEST
controller.async
worker.callable:requestId=context-01
filter.enter:ASYNC
body.beforeWrite这是需要存在的事件集合,工作线程与第一次分派退出的先后可能交错。完整记录还包含各分派退出和拦截回调;观察到两次 Filter 进入,是配置允许 ASYNC 后的正常结果。
将标识改为空格等非法内容,再检查响应头,会得到服务器生成的新标识。正常响应通过 --fail-with-body 检查,-q 与 --noproxy '*' 隔离 curlrc 和代理;选项见 curl 手册。
线程复用和异常清理
在解包工程中运行针对性测试:
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=ContextPropagationTest,DispatcherChainTest testPROJECT_DIR 与 LAB_DIR 来自准备步骤。测试应成功退出。ContextPropagationTest 使用真实单线程池,依次检查以下结果:
| 操作 | 断言结果 |
|---|---|
| 先建立工作线程,再在提交线程放入 request-plain | 未装饰的任务读不到 requestId |
| 装饰时捕获 request-captured,提交前改为 request-later | 任务仍读到捕获值,提交线程保留新值 |
| 工作线程原有 worker-before | 任务结束后恢复 worker-before |
| 装饰任务抛出异常,复用同一工作线程 | 下一任务读不到失败任务留下的 requestId |
这些检查能识别“第一次看起来正常,第二次线程复用才串号”的问题。仅创建新线程执行一次,很难暴露相同错误。
判断重复执行与缺少上下文
| 现象 | 检查方向 | 对应修正 |
|---|---|---|
| 同一次 REQUEST 连续出现两次同名 Filter | 实例数量、自动注册和手工注册 | 保留一个明确注册位置 |
| REQUEST、ASYNC 各出现一次 | dispatcher type 与 OncePerRequestFilter 设置 | 按分派解释,不直接当作重复请求 |
| Callable 日志没有标识 | MVC 使用的 executor 是否安装装饰器 | 检查 configureAsyncSupport 的实际执行器 |
| 自管任务没有标识 | 是否绕过了已配置的 executor | 在它的提交入口明确传播 |
| 下一请求带上前一请求的值 | finally 是否覆盖失败路径 | 恢复原值,补线程复用测试 |
| ERROR 分派丢失包装或上下文 | 注册类型及嵌套错误分派路径 | 区分 doFilterInternal 与嵌套 ERROR 入口 |
| 请求日志完成,后台仍在运行 | 记录的是初次分派耗时 | 增加任务和完整异步请求的独立观察 |
如果异常只在高并发时出现,先记录线程名、dispatcher type 与受控请求标识,比较同一次请求的事件关系。线程名适合诊断,业务正确性不能依赖线程名始终不变。
实验结束时停止并删除 mvc09-binding;源码和缓存保留。生产中删除 MDC 信息只是线程清理的一部分,任务句柄、流订阅和数据库连接还要由各自的生命周期释放。
