读完这篇,你能做什么
能画出常见 MVC 请求链,并避免在共享成员字段存放当前用户状态。
学习理由、准备要求与知识点
为什么学它
请求过滤、分发和业务处理的位置决定横切逻辑与请求状态应放在哪里。
先读这些内容
知识点清单
- Servlet 容器分发
- Filter 前后处理
chain.doFilterDispatcherServlet- Handler 与 Interceptor
- 实例并发调用
- 请求数据作用域
- 转发与重定向
运行前先确认以 Java 17、Jakarta Servlet 6.0、Tomcat 10.1.44、Spring Framework 6.2.11 的同步 MVC 请求为主线。算法是离线的过滤链控制流模型;异步分派另有生命周期,不能把本例调用栈结束当作所有响应均已结束。
01先弄懂它是什么
请求到达 Java Web 应用后,会先由 Servlet 容器接收和分派,再经过相应 Filter;Spring MVC 通常由 DispatcherServlet 查找 Controller 方法。画出进入和返回的路线,就能解释请求为什么还没到 Controller 已被拒绝,或同一处日志为什么出现多次,而不必一开始就怀疑业务代码。
Filter 可以在 chain.doFilter 前检查或包装请求,调用后再处理结果,也可以直接响应并不再往下走。因此它不是简单的一列函数:前面的处理按进入顺序执行,调用后的处理则随返回过程倒序执行。中途拒绝或抛异常时,后面哪些步骤会执行,也要按实际路线判断。
Filter 工作在 Servlet 容器这一层;Interceptor 工作在 Spring MVC 里,更接近已经找到的处理方法。它们能拦住哪些请求、何时被调用,要分别判断。身份和权限检查应由统一机制负责,不能只在几个页面入口零散添加。
Controller 负责接入 HTTP 请求和返回响应,Service 组织业务操作,Mapper 访问数据。这样划分是为了看清每一步做什么、哪里出错,并不要求每次请求都必须多绕几层。
文中这几个词,先认一认
- 前端控制器
- 框架统一接收请求、找处理方法并组织参数与返回结果的入口;这里不是指浏览器页面。
- 继续执行对象
- 代表剩余处理过程的调用入口,当前 Filter 调用它,请求才继续往后走。
- 短路
- 当前步骤直接完成或拒绝请求,不再调用后面的处理过程。
- 请求范围
- 只属于这次请求的数据存活范围,不能直接放进许多请求共用的 Servlet 成员字段。
02看看它怎么工作
假设有日志 Filter A、认证 Filter B 和 DispatcherServlet。正常请求依次进入 A、B、Servlet,返回时先完成 B 的后置逻辑,再完成 A。B 拒绝时,Servlet 不会被调用,但 A 已经进入,因此其 finally 仍应执行。日志中的缺失节点可能是合法短路,不一定是路由系统坏了。
清理代码应放进 finally,不能只写在 chain.doFilter 后面:后续步骤一旦抛异常,普通的后续语句就会被跳过,而已使用的连接、MDC 日志上下文或其他临时状态仍需要清理。这里的 finally 只对应当前同步方法退出;进程被强制结束时不保证执行,异步请求也可能在它执行后继续处理。设计时要分别确定这些情况由谁负责收尾。
同一 Servlet 或过滤器实例可以被多个请求并发调用。把当前用户或查询结果放进成员字段,会让请求互相覆盖;方法局部变量或适当请求对象更符合归属。请求包装器也应遵守容器契约,不能把同一请求对象随意跨到请求结束后的线程继续使用。转发属于容器内再分派,重定向要求客户端发起新请求,两者会影响链路与上下文延续。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["进入日志过滤器 A"] --> B["进入认证过滤器 B"]
B --> C{"允许继续"}
C -->|"是"| D["DispatcherServlet 映射处理器"]
D --> E["MVC 拦截器与 Controller"]
E --> F["业务服务与数据访问"]
C -->|"否"| G["产生拒绝响应"]
F --> H["退出 B 的清理"]
G --> H
H --> I["退出 A 的清理"]按顺序看,每一步做了什么
- 从容器映射和过滤器顺序确认真实入口。
- 记录每个节点的进入、是否继续以及异常。
- 调用后续链路时使用 finally 保护本节点已经获得的资源。
- 在 MVC 内继续区分拦截、参数绑定、业务执行和响应转换。
03对照实现代码
Apache Tomcat 10.1.44 · org.apache.catalina.core.ApplicationFilterChain#internalDoFilter
已核验真实类、pos/n 游标与最终 servlet.service 调用。下面是原创 Java 17 局部模型,省略 Servlet API、权限、包装器与异步支持检查;继续对象按一次请求创建,不能共享其可变游标。
import java.util.List;
class SourceModel {
interface Filter { void apply(Runnable next); }
static class Chain {
final List<Filter> filters;
final Runnable servlet;
int position=0; // 仅属于本次链路,不是全局请求进度
Chain(List<Filter> filters,Runnable servlet) { this.filters=filters; this.servlet=servlet; }
void proceed() {
if(position<filters.size()) {
Filter current=filters.get(position++); // 调用前推进,避免重复当前节点
current.apply(this::proceed); // 当前 Filter 决定是否继续
return;
}
servlet.run(); // 所有已配置过滤器通过后,才到 Servlet
}
}
}internalDoFilter 用 pos 记住“下一步该执行第几个过滤器”,用 n 记录过滤器总数。如果还有下一项,它先取出这一项并把 pos 加一,再调用 filter.doFilter。这样,过滤器调用 chain 继续处理时,就从后面的一项接着走,不会又回到自己。这个记录当前位置的数字,就是源码里的游标。
当前 Filter 返回后,调用链也随之返回;只有走完所有过滤器,才会调用 servlet.service。因此拒绝请求时,Filter 只要不再调用 chain,后续处理自然不会发生,不需要另一个取消开关。反过来,重复调用 chain 可能让终点多次执行,应明确每次只继续一次。
真实链还会根据过滤器与 Servlet 的异步支持设置请求属性,并处理异常传播和相关容器边界。教学模型没有这些机制,因此它只能验证调用顺序。阅读源码时应把核心算法与运行环境条件分开,不用一个十行模型宣称完全复现容器。
DispatcherServlet.doDispatch 在 Servlet 内部进一步完成处理器映射、适配器调用与结果处理。Spring 拦截器的回调契约与 Filter 的普通 Java try/finally 不完全相同;实际检查哪个回调触发,应查看对应框架调用链,而不能只按名称中的前置、后置自行猜测。
代码先调用谁,再调用谁
ApplicationFilterChain.doFilter进入容器链。internalDoFilter读取并推进当前游标。- 当前 Filter 决定是否调用下一个节点。
- 链尽头调用 Servlet,再进入
DispatcherServlet的 MVC 分派。
04动手写一个例子
生成带短路与异常的过滤链执行轨迹
输入有序过滤器列表,每项为 PASS、STOP 或 FAIL。进入时记录 in,退出时无论是否异常都记录 out;PASS 继续,STOP 记录 stop 后短路,FAIL 抛异常;全部通过才记录 handler。返回轨迹,用它验证实际过滤器的清理顺序。
- 输入 1
A PASS,B PASS- 输出 1
A.in,B.in,handler,B.out,A.out
- 输入 2
A PASS,B STOP,C PASS- 输出 2
A.in,B.in,B.stop,B.out,A.out
- 输入 3
A PASS,B FAIL- 输出 3
A.in,B.in,B.out,A.out,error
- 输入 4
空链- 输出 4
handler
过滤器包围后续链路,因此递归调用自然对应嵌套执行栈。每层先记录进入,按模式决定是否递归,finally 记录退出;终点只负责 handler。这样输出顺序由控制流产生,不是事后把名字反转拼接。
STOP 只阻止尚未进入的节点。已经进入的上层仍会返回并清理,所以 B 短路后 A.out 必須出现。FAIL 则沿栈抛出异常,finally 仍依次执行,外层测试入口最后记录 error。
算法有深度与资源边界:每个已进入节点占用一层栈,大量动态链可改用显式栈模型。main 固定覆盖正常、短路、异常、空链、首项拒绝与非法项,验证未进入节点没有 out,且 handler 不会在拒绝后出现。
为什么这样写是对的轨迹中的每个已进入节点恰好有一次 out,且退出顺序与活跃进入栈相反;短路或异常后的未进入节点没有任何事件。
k 为实际进入节点数,每层执行常数动作;递归栈与返回轨迹均线性,输出本身就需要 O(k) 空间。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
enum Mode { PASS, STOP, FAIL }
record Step(String name,Mode mode) {
Step { if(name==null || name.isBlank() || mode==null) throw new IllegalArgumentException(); }
}
static void visit(List<Step> steps,int i,List<String> trace) {
if(i==steps.size()) { trace.add("handler"); return; }
Step step=steps.get(i);
trace.add(step.name()+".in"); // 只为真正进入的节点记录生命周期
try {
if(step.mode()==Mode.STOP) { trace.add(step.name()+".stop"); return; }
if(step.mode()==Mode.FAIL) throw new IllegalStateException("模拟失败");
visit(steps,i+1,trace); // 后续链路结束后才会退回本层
} finally {
trace.add(step.name()+".out"); // 正常、短路和异常都必须清理
}
}
static String run(List<Step> steps) {
if(steps==null || steps.stream().anyMatch(Objects::isNull)) throw new IllegalArgumentException();
List<String> trace=new ArrayList<>();
try { visit(steps,0,trace); }
catch(IllegalStateException failure) { trace.add("error"); }
return String.join(",",trace);
}
static Step s(String name,Mode mode) { return new Step(name,mode); }
static void eq(String expected,String actual) { if(!expected.equals(actual)) throw new AssertionError(actual); }
public static void main(String[] args) {
eq("A.in,B.in,handler,B.out,A.out",run(List.of(s("A",Mode.PASS),s("B",Mode.PASS))));
eq("A.in,B.in,B.stop,B.out,A.out",run(List.of(s("A",Mode.PASS),s("B",Mode.STOP),s("C",Mode.PASS))));
eq("A.in,B.in,B.out,A.out,error",run(List.of(s("A",Mode.PASS),s("B",Mode.FAIL))));
eq("handler",run(List.of()));
eq("A.in,A.stop,A.out",run(List.of(s("A",Mode.STOP))));
try { new Step("",Mode.PASS); throw new AssertionError("应拒绝空名称"); }
catch(IllegalArgumentException expected) { /* 错误输入边界 */ }
System.out.println("PASS 6 filter chain tests");
}
}这些特殊情况也要试一试
- 空链仍应调用终点,而不是直接无响应。
- 在第一项短路时,只清理第一项。
- 清理逻辑本身抛异常时要定义异常保留规则,本题仅模拟不会失败的轨迹追加。
进阶选读 · 生产拓展:异步继续与恰好一次清理
场景链路在其他线程继续,原调用栈已退出,不能再用同步 finally 代表最终请求结束。
做法将清理绑定到明确的异步完成、错误和超时信号,用完成状态防止重复释放;记录已获取资源及其所有者,不把请求上下文无限传给任意线程。
复杂度状态记录随在途请求和资源数增长;回调次数有限时每个资源可做到常数次状态转换。
好处与代价显式生命周期适合异步,但比同步栈模型更复杂,尤其需要处理超时与正常完成竞争。
05项目里怎么用
统一功能要放在确实能覆盖目标请求的位置。编码处理、请求包装、日志和安全检查适合的位置未必相同;某个拦截器好用,不代表它覆盖静态资源、错误再次分派或所有内部调用。先列出要处理哪些进入方式,再决定放在哪里和怎样配置。
清理责任应与获取责任配对。MDC、ThreadLocal 或临时资源由哪个节点设置,就在对应生命周期结束时清理或恢复原值。共享线程上的残留上下文可能影响下一请求,因此异常路径和短路路径都要写用例,不能只验证正常 Controller 返回。
记录请求时要有编号、进入的节点和当前处理阶段。把整个响应缓存下来再记录,可能增加内存、打断流式输出或泄露敏感数据,应限制类型和大小。同步方法的计时只覆盖这次调用;异步请求真正何时结束,要关联完成事件再统计。
容易踩的坑
- 忘记调用
chain.doFilter又没有产生响应,可能让请求在错误位置终止。 - 只在正常返回后清理上下文,异常与拒绝路径会残留状态。
- 把当前用户存进共享 Filter 或 Servlet 成员字段。
- 把 Filter、MVC Interceptor 和 AOP 方法拦截当成同一层。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| Servlet Filter | 位于容器链,适合通用请求与响应处理。 | 需要理解分派、包装器与异步生命周期。 | 编码、受控日志和统一入口机制。 |
| MVC Interceptor | 更接近框架处理器与映射信息。 | 覆盖范围和回调顺序受 MVC 契约约束。 | 与 MVC 处理流程明确相关的协调。 |
| Service 或方法层策略 | 能围绕业务操作表达权限与不变量。 | 无法独自完成所有 HTTP 层处理。 | 资源所有权、业务规则和事务边界。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 Filter 的后置顺序通常与前置相反?
展开参考答案与推导
每个过滤器把后续链放在自己的方法调用内部,返回时按调用栈退回。它是嵌套控制流,不是两个独立的正序循环。
02短路之后哪些清理仍应发生?
展开参考答案与推导
已经进入且获得资源的节点仍需清理,尚未进入的节点没有相应生命周期。正确 finally 能覆盖当前同步栈的短路与异常。
03Filter 与 Interceptor 为什么不能互换推断行为?
展开参考答案与推导
二者位于容器和 MVC 的不同链路,覆盖入口、分派类型与回调契约不同。应按实际框架源码和配置判断。
换个情况,还会处理吗
01Controller 没日志但请求返回 401,先查哪里?
展开参考答案与推导
查看安全过滤链或更早入口是否已拒绝,核对请求标识与分派。没有进入 Controller 可能是预期安全短路,不应立即改路由。
02偶尔出现上一用户身份,如何检查?
展开参考答案与推导
检查共享字段和 ThreadLocal 的设置清理链,特别是异常、拒绝与异步路径;确认身份来自可信安全上下文而非客户端任意字段。
03Filter 计时很短但异步请求很慢,是否计时器坏了?
展开参考答案与推导
它可能只测量初始同步调用栈,而异步处理随后继续。应将总耗时绑定到最终完成或超时事件,并区分各阶段。
写代码试一试
01给三层链推导中间拒绝轨迹。
- 输入 / 条件
A PASS,B STOP,C PASS- 输出 / 预期
A.in,B.in,B.stop,B.out,A.out
提示 只对真正进入的节点退栈。
展开参考答案与推导
A 调用 B,B 不继续到 C;B 的 finally 先运行,再回到 A 的 finally,终点和 C 都不出现。
02如何防止一个过滤器重复调用 next?
- 输入 / 条件
同一节点调用 next 两次- 输出 / 预期
第二次被拒绝或明确记录为契约错误
提示 将继续入口包装为每节点一次性令牌。
展开参考答案与推导
在调用前检查并设置 proceeded 状态;并发异步时还需原子状态转换。该保护属于额外契约,不能假设容器自动帮应用消除重复调用。
03把递归链改为显式栈需要保存什么?
- 输入 / 条件
多个 PASS 节点后出现 FAIL- 输出 / 预期
按已进入节点逆序执行清理
提示 进入和退出是两类动作。
展开参考答案与推导
记录已进入节点及清理动作,终点、短路或异常发生后依次弹栈。复杂过滤器内部控制流不能仅靠反转名称完整模拟。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能画出常见 MVC 请求链,并避免在共享成员字段存放当前用户状态。
本篇重点加练
重点验证过滤链推进与请求数据作用域,防止链路控制和数据生命周期混杂。
chain.doFilter- 请求数据作用域
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为请求
/notes/7画出从容器到 Filter、DispatcherServlet再返回的调用链,分别标出谁负责继续分发、谁可拒绝、谁负责响应收尾。- 提交内容
- 带调用与返回箭头的流程图。
- 怎样算完成
- 职责分工完整
- 短路分支有终点
练到的知识点Servlet 容器分发 · Filter 前后处理 · chain.doFilter · DispatcherServlet
- 作业 02代码推演
过滤器 F1、F2 和两个 MVC 拦截器围绕一个控制器。自行给出正常、F2 拒绝、控制器抛错三组配置,预测可观测事件后在最小工程验证,不假设所有回调必然执行。
- 提交内容
- 配置、预测表和逐条事件记录。
- 怎样算完成
- 注明容器与框架版本
- 区分 Filter 与拦截器回调合同
练到的知识点Filter 前后处理 · chain.doFilter · Handler 与 Interceptor
综合应用
- 作业 03面试场景
同一个 Servlet 实例服务用户甲和乙,请求线程在赋值
userId后被切换。评审把userId、共享只读配置、统计计数分别保存在字段中的方案。- 提交内容
- 字段分类、并发交错图及修改方案。
- 怎样算完成
- 按数据生命周期分类
- 考虑统计更新的复合操作
练到的知识点实例并发调用 · 请求数据作用域
- 作业 04排错实验
在本地工程增加一次 forward 与一次 redirect,让入口写入请求属性并记录请求方法、路径和关联标识。用客户端跟踪跳转,检查两条路径的数据作用域和实际请求次数。
- 提交内容
- 可复现实验、自写请求断言、服务日志与客户端记录。
- 怎样算完成
- 观察真实分发链
- 不只依据地址栏下结论
练到的知识点请求数据作用域 · 转发与重定向
- 作业 05编程实践
编写请求耗时 Filter,为每次请求生成关联标识,覆盖成功、拒绝和异常三种请求;日志不得保存共享可变的“当前请求”字段。
- 提交内容
- 过滤器、三类测试请求、边界测试及运行证据。
- 怎样算完成
- 每次请求关联信息独立
- 失败路径也有可追溯记录
- 说明测量覆盖的执行范围
练到的知识点Filter 前后处理 · chain.doFilter · 实例并发调用 · 请求数据作用域
- 作业 06编程实践
实现不依赖框架的过滤链模型,某层可以补充
requestId后继续或拒绝;同一链连续处理两份请求,记录每份请求自己的属性和事件。- 提交内容
- Java 模型、自写正常/拒绝/连续请求测试与运行证据。
- 怎样算完成
- 链推进次数可观察
- 请求属性之间按合同隔离
练到的知识点chain.doFilter · 请求数据作用域
- 作业 07排错实验
在本地练习 Filter 中构造“既写响应又继续推进”和“未写响应也未推进”两种情况,观察客户端结果与后续控制器事件;逐一恢复并记录。
- 提交内容
- 两组最小配置、请求脚本和真实事件记录。
- 怎样算完成
- 没有靠日志缺失猜测执行
- 实验有响应超时与清理上限
练到的知识点chain.doFilter
- 作业 08设计评审
鉴权结果先由 Filter 生成,Controller 和日志组件都需读取。设计请求内属性名、类型和修改权限,说明错误分发再次进入时谁可覆盖它。
- 提交内容
- 属性合同及分发时间线。
- 怎样算完成
- 数据所有者明确
- 不同读取方使用同一合同
练到的知识点请求数据作用域
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成异步 Servlet 后:一条请求发生 REQUEST、ASYNC 和 ERROR 分发,要求认证检查和计时各有明确执行次数。设计分发类型策略与验证矩阵。
- 提交内容
- 分发策略表、事件标识设计与验收条件。
- 怎样算完成
- 重复分发与重复业务执行分开
- 覆盖异步完成边界
练到的知识点Servlet 容器分发 · chain.doFilter · Handler 与 Interceptor · 转发与重定向
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 跨域迁移
把同步 Filter 链迁移为消息消费处理链,消息可能稍后重试。比较继续推进、短路、上下文保存与清理的含义;未学消息队列时按本地事件列表推演。
- 提交内容
- 对应关系图和一次重试序列。
- 怎样算完成
- 请求生命周期假设得到审查
- 迁移后的状态归属可验证
练到的知识点chain.doFilter · 请求数据作用域
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读规范英文
Jakarta Servlet 6.0 规范 (新标签页)↗
阅读生命周期、并发请求、分派类型和异步处理;用于 Jakarta 命名空间课程。
- 实践参考文档英文
Spring MVC:DispatcherServlet (新标签页)↗
阅读前端控制器与处理器查找流程,连接容器、Filter 和 Controller。
- 深入参考文档英文
Spring Framework:Bean 作用域 (新标签页)↗
比较 singleton、prototype 与请求作用域,检查对象状态和销毁责任。
- 深入参考文档英文
Jakarta Servlet 6.0 Specification (新标签页)↗
阅读过滤器、分派类型、线程和异步约束。
- 深入参考文档英文
Tomcat 10.1.44 ApplicationFilterChain (新标签页)↗
核对游标推进、继续调用与 Servlet 终点。
- 深入参考文档英文
Spring 6.2.11 DispatcherServlet (新标签页)↗
定位
doDispatch在 Servlet 内的 MVC 协调流程。