qinghuan
← Java Web / 全部笔记

单元 39 / Java Web

P0 · 必会主线L1 · 基础

Servlet、Filter 与 MVC:请求经过哪些关口

把容器、Filter、DispatcherServlet 和 Controller 连成请求路线,理解谁能拦截请求、返回时又经过哪里。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能画出常见 MVC 请求链,并避免在共享成员字段存放当前用户状态。

学习理由、准备要求与知识点

为什么学它

请求过滤、分发和业务处理的位置决定横切逻辑与请求状态应放在哪里。

先读这些内容

知识点清单

  • Servlet 容器分发
  • Filter 前后处理
  • chain.doFilter
  • DispatcherServlet
  • 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 的清理"]

按顺序看,每一步做了什么

  1. 从容器映射和过滤器顺序确认真实入口。
  2. 记录每个节点的进入、是否继续以及异常。
  3. 调用后续链路时使用 finally 保护本节点已经获得的资源。
  4. 在 MVC 内继续区分拦截、参数绑定、业务执行和响应转换。

03对照实现代码

ApplicationFilterChain 的游标与 Servlet 终点 (新标签页)↗

Apache Tomcat 10.1.44 · org.apache.catalina.core.ApplicationFilterChain#internalDoFilter

已核验真实类、pos/n 游标与最终 servlet.service 调用。下面是原创 Java 17 局部模型,省略 Servlet API、权限、包装器与异步支持检查;继续对象按一次请求创建,不能共享其可变游标。

核心机制代码 · java
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 不完全相同;实际检查哪个回调触发,应查看对应框架调用链,而不能只按名称中的前置、后置自行猜测。

代码先调用谁,再调用谁

  1. ApplicationFilterChain.doFilter 进入容器链。
  2. internalDoFilter 读取并推进当前游标。
  3. 当前 Filter 决定是否调用下一个节点。
  4. 链尽头调用 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,且退出顺序与活跃进入栈相反;短路或异常后的未进入节点没有任何事件。

时间 O(k)额外空间 O(k)

k 为实际进入节点数,每层执行常数动作;递归栈与返回轨迹均线性,输出本身就需要 O(k) 空间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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
  • 请求数据作用域

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为请求 /notes/7 画出从容器到 Filter、DispatcherServlet 再返回的调用链,分别标出谁负责继续分发、谁可拒绝、谁负责响应收尾。

    提交内容
    带调用与返回箭头的流程图。
    怎样算完成
    • 职责分工完整
    • 短路分支有终点

    练到的知识点Servlet 容器分发 · Filter 前后处理 · chain.doFilter · DispatcherServlet

  2. 作业 02代码推演

    过滤器 F1、F2 和两个 MVC 拦截器围绕一个控制器。自行给出正常、F2 拒绝、控制器抛错三组配置,预测可观测事件后在最小工程验证,不假设所有回调必然执行。

    提交内容
    配置、预测表和逐条事件记录。
    怎样算完成
    • 注明容器与框架版本
    • 区分 Filter 与拦截器回调合同

    练到的知识点Filter 前后处理 · chain.doFilter · Handler 与 Interceptor

综合应用

  1. 作业 03面试场景

    同一个 Servlet 实例服务用户甲和乙,请求线程在赋值 userId 后被切换。评审把 userId、共享只读配置、统计计数分别保存在字段中的方案。

    提交内容
    字段分类、并发交错图及修改方案。
    怎样算完成
    • 按数据生命周期分类
    • 考虑统计更新的复合操作

    练到的知识点实例并发调用 · 请求数据作用域

  2. 作业 04排错实验

    在本地工程增加一次 forward 与一次 redirect,让入口写入请求属性并记录请求方法、路径和关联标识。用客户端跟踪跳转,检查两条路径的数据作用域和实际请求次数。

    提交内容
    可复现实验、自写请求断言、服务日志与客户端记录。
    怎样算完成
    • 观察真实分发链
    • 不只依据地址栏下结论

    练到的知识点请求数据作用域 · 转发与重定向

  3. 作业 05编程实践

    编写请求耗时 Filter,为每次请求生成关联标识,覆盖成功、拒绝和异常三种请求;日志不得保存共享可变的“当前请求”字段。

    提交内容
    过滤器、三类测试请求、边界测试及运行证据。
    怎样算完成
    • 每次请求关联信息独立
    • 失败路径也有可追溯记录
    • 说明测量覆盖的执行范围

    练到的知识点Filter 前后处理 · chain.doFilter · 实例并发调用 · 请求数据作用域

  4. 作业 06编程实践

    实现不依赖框架的过滤链模型,某层可以补充 requestId 后继续或拒绝;同一链连续处理两份请求,记录每份请求自己的属性和事件。

    提交内容
    Java 模型、自写正常/拒绝/连续请求测试与运行证据。
    怎样算完成
    • 链推进次数可观察
    • 请求属性之间按合同隔离

    练到的知识点chain.doFilter · 请求数据作用域

  5. 作业 07排错实验

    在本地练习 Filter 中构造“既写响应又继续推进”和“未写响应也未推进”两种情况,观察客户端结果与后续控制器事件;逐一恢复并记录。

    提交内容
    两组最小配置、请求脚本和真实事件记录。
    怎样算完成
    • 没有靠日志缺失猜测执行
    • 实验有响应超时与清理上限

    练到的知识点chain.doFilter

  6. 作业 08设计评审

    鉴权结果先由 Filter 生成,Controller 和日志组件都需读取。设计请求内属性名、类型和修改权限,说明错误分发再次进入时谁可覆盖它。

    提交内容
    属性合同及分发时间线。
    怎样算完成
    • 数据所有者明确
    • 不同读取方使用同一合同

    练到的知识点请求数据作用域

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成异步 Servlet 后:一条请求发生 REQUEST、ASYNC 和 ERROR 分发,要求认证检查和计时各有明确执行次数。设计分发类型策略与验证矩阵。

    提交内容
    分发策略表、事件标识设计与验收条件。
    怎样算完成
    • 重复分发与重复业务执行分开
    • 覆盖异步完成边界

    练到的知识点Servlet 容器分发 · chain.doFilter · Handler 与 Interceptor · 转发与重定向

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把同步 Filter 链迁移为消息消费处理链,消息可能稍后重试。比较继续推进、短路、上下文保存与清理的含义;未学消息队列时按本地事件列表推演。

    提交内容
    对应关系图和一次重试序列。
    怎样算完成
    • 请求生命周期假设得到审查
    • 迁移后的状态归属可验证

    练到的知识点chain.doFilter · 请求数据作用域

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗