Skip to content

HTTP 请求响应与 Servlet ​

S3-018必学进阶约 90 分钟JDK 21企业开发基础 · Java Web 基础

1. 本节定位 ​

前面写过很多 @RestController(《Spring MVC 请求映射与参数绑定》),本节要往下挖一层:

text
你写的:      @GetMapping("/api/orders/{orderNo}") public OrderResponse find(...) { }
                  ↓ 这层封装下面是什么?
本节:        DispatcherServlet(一个货真价实的 Servlet)
                  ↓ 再下面?
Tomcat:      解析 TCP 字节流 → 构成 HttpServletRequest → 交给 Servlet

为什么要往下看:出了问题时,报错信息往往在下面那一层——

你遇到过的现象真正的原因在
接口 404 但代码没问题Servlet 映射(DispatcherServlet 的 URL 模式)
中文乱码HTTP 报文的字符集(Content-Type 里的 charset)
请求体只能读一次Servlet 的 InputStream 语义
"为什么我的 Controller 是单例,却不怕并发?"Servlet 本来就是单例多线程

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 HTTP 请求报文与响应报文各自的三部分结构;
  • 说出 Servlet 的生命周期(三个方法、各自被调用几次);
  • 说出"Servlet 是单例、被多线程共享"意味着什么,以及为什么 Servlet 里不该放可变状态;
  • 用 HttpServletRequest 取出方法、URI、头、参数、请求体;
  • 用 HttpServletResponse 写状态码、响应头、响应体;
  • 说出 DispatcherServlet 与普通 Servlet 的关系;
  • 说出"请求体只能读一次"的原因与应对。

3. 前置知识检查 ​

  • 知道 HTTP 是什么、一次请求包含什么吗?
  • 会基本的 Java IO(InputStream / Reader)吗?(《泛型基础》)

说明:先修章节 《HTTP 与 HTTPS》(网络基础)尚未产出。本节会在 5.1 节自带必要的 HTTP 报文知识,所以即使没学过网络基础也能跟上。

4. 为什么需要这个知识 ​

先看一个真实的困惑:

java
@RestController
public class OrderController {
    private int count = 0;                    // ⚠️ 这个字段线程安全吗?

    @GetMapping("/count")
    public int count() {
        return ++count;                       // ⚠️ 并发下会丢更新
    }
}

为什么值得警惕:Spring MVC 的 Controller 默认是单例——多个请求会同时调用同一个对象的方法 ✓

那么这个"单例"是从哪来的?

因为 Controller 最终是被 DispatcherServlet 调用的,而 Servlet 本身就是"容器里只有一个实例、多线程共用一个对象" ✓

Servlet 规范里的这条设计(单例 + 多线程)是从 1997 年延续至今的,而它的所有上层框架(Spring MVC、Struts、JAX-RS)都继承了这条约束 ✓

理解它,才能理解:

现象原因
Controller 里不能放可变字段Servlet 单例多线程
HttpServletRequest 可以放心用容器保证每个请求一个独立的 request 对象
ThreadLocal 能"记住当前请求"请求处理在一个线程里完成(但异步时会变)

5. 核心概念 ​

5.1 HTTP 报文长什么样 ​

请求报文(三部分):

text
POST /api/orders HTTP/1.1                      ← ① 请求行:方法 + 路径 + 协议版本
Host: localhost:18080                          ← ② 请求头(若干行 key: value)
Content-Type: application/json
Authorization: Bearer xxx
Content-Length: 67
                                               ← ③ 空行(分隔头与体)
{"productId":"P-1","quantity":2}                ← ④ 请求体(可选)

响应报文:

text
HTTP/1.1 201 Created                           ← ① 状态行:版本 + 状态码 + 原因短语
Location: /api/orders/ORD-1                    ← ② 响应头
Content-Type: application/json
Content-Length: 128
                                               ← ③ 空行
{"orderNo":"ORD-1",...}                         ← ④ 响应体

四件值得单独说明的事:

点说明
头与体之间必须有一个空行这是 HTTP 的语法要求,不是"排版"⚠️
Content-Length 或 Transfer-Encoding告诉对方"体有多长"——没有它就无法判断读到哪里为止
Content-Type 决定怎么解析体application/json / text/plain / multipart/form-data……
charset不写它,中文就可能乱码 ✗(本节实测里显式写了 charset=UTF-8)

HTTP 是"无状态"的:

协议本身不记录"你是谁"——两次请求之间,服务器默认不认识它们来自同一个浏览器 ✓

"记住登录状态"是靠额外机制实现的(Cookie/Session/Token,见 《Filter、Listener 与会话管理》)✓

5.2 Servlet 是什么 ​

Servlet 是 Java 对"处理 HTTP 请求"这件事的抽象:

java
public class RawServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) { ... }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) { ... }
}

它的设计非常"原始":

特征说明
一个 Servlet 对应一组 URL没有注解路由——/raw/* 这一组全归它
方法按 HTTP 方法分派doGet / doPost / doPut / doDelete
参数要自己从 request 里取没有自动绑定(《Spring MVC 请求映射与参数绑定》 的三种绑定都是封装)
响应要自己写没有自动序列化(Content-Type 都得手动设)

容器(Tomcat)负责的事:

事说明
监听端口、解析 TCP把字节流变成 HttpServletRequest
管理 Servlet 生命周期创建、初始化、销毁
分配工作线程每个请求用一个线程(从线程池取)
写回响应把 HttpServletResponse 变成字节流发出去

⚠️ "分配线程"这条最关键,见 5.4。

5.3 生命周期:三个方法的调用次数 ​

java
public class RawServlet extends HttpServlet {
    @Override
    public void init() throws ServletException {
        // ① 容器启动(或首次访问)时调用一次
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        // ② 每个 GET 请求调用一次(并发请求会在不同线程里同时进入)
    }

    @Override
    public void destroy() {
        // ③ 容器关闭时调用一次
    }
}

实测输出(本项目启动后的日志):

text
[Servlet 生命周期] init() —— 容器启动时调用一次;此后这个实例被复用

init() 只出现一次(尽管后面处理了 4 个请求)✓

方法调用次数谁调用
init()一次容器
service() → doGet / doPost每个请求一次容器(在工作线程里)
destroy()一次容器(关闭时)

这三条约束推出了两个重要结论:

结论原因
init() 里的东西是"全局共享"的只执行一次 → 适合做"初始化",不适合放请求相关的状态
doGet 里的字段会被并发访问多线程同时进入同一对象 ✗

5.4 Servlet 是单例:为什么不能放可变状态 ​

实测证据:同一张 Servlet 上有两个请求,访问计数是累加的:

text
===== 收到一个请求(第 1 次)=====
请求方法:GET
请求 URI :/raw/hello
查询串   :name=zhangsan&age=18
线程名   :http-nio-18090-exec-1(Tomcat 的工作线程池)

===== 收到一个请求(第 2 次)=====
请求方法:POST
线程名   :http-nio-18090-exec-2(Tomcat 的工作线程池)

两处信息:

观察含义
"第 1 次"→"第 2 次"同一个 Servlet 实例在服务两个请求 ✓(否则计数会重置)
exec-1 → exec-2两个不同的工作线程 ✓(否则"单例"就没意义了)

而那个计数器为什么没出错:因为它用的是 AtomicInteger ✓

java
private final AtomicInteger visitCount = new AtomicInteger();      // ✓ 线程安全
// private int visitCount = 0;                                      // ✗ 并发下会丢更新

这就是 《线程与同步基础》 讲过的内容在 Web 场景的落地 ✓

Servet 里"什么是安全的、什么是危险的":

放什么安全吗原因
final 的、不可变的对象(如配置、ObjectMapper)✅ 安全多线程只读 ✓
AtomicInteger / ConcurrentHashMap 等并发容器✅ 安全有内存可见性与原子性保证 ✓
普通可变的实例字段❌ 危险多线程读写会互相干扰 ✗
HttpServletRequest / HttpServletResponse✅ 安全容器保证"每个请求一个独立对象" ✓
ThreadLocal⚠️ 谨慎用对了很方便(如 MDC),用错了会泄漏(《日志框架与日志规范》 讲过)

一句可以直接用的规则:

Servlet / Controller / Service 里的实例字段,要么是不可变的,要么是线程安全的容器——否则就别写。

所有"每个请求都不一样"的数据,一律用方法参数(或 ThreadLocal)传递,不要放字段。 ✓

5.5 DispatcherServlet 就是一个 Servlet ​

Spring MVC 并没有绕开 Servlet 规范,它只是注册了一个"总入口 Servlet":

text
所有请求 /  →  DispatcherServlet(url-pattern 通常是 "/")
                     ↓ 它内部再做"路由到具体方法"的事
                HandlerMapping → Controller 方法

对照表:

Servlet 世界的概念Spring MVC 里的对应
ServletDispatcherServlet(唯一一个)
url-pattern@RequestMapping(它内部的路由表)
doGet / doPost@GetMapping / @PostMapping
从 request.getParameter() 取值@RequestParam / @PathVariable
自己往 response 写返回值 + HttpMessageConverter
web.xml 里配置自动配置(《Spring Boot 自动配置与项目结构》)

这张表的价值:你在 Spring MVC 里遇到的"底层问题",都能在这张表里找到归处 ✓

现象对照到 Servlet 世界
静态资源 404DispatcherServlet 的 url-pattern 与静态资源处理的冲突 ⚠️
请求体读两次报错Servlet 的 InputStream 只能读一次(5.6)
上传大文件失败MultipartResolver 的配置(底层是 Servlet 的 multipart 支持)
中文乱码request.setCharacterEncoding / Content-Type 的 charset

5.6 请求体只能读一次 ​

在 Servlet 层面,请求体就是一个 InputStream:

java
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);

读完之后流指针到末尾——再读就是空的 ✗

这就解释了 Spring MVC 里那条限制(《Spring MVC 请求映射与参数绑定》 的 5.3 节):

java
public void create(@RequestBody CreateOrderRequest request, HttpServletRequest raw) {
    raw.getInputStream().readAllBytes();      // ✗ 已经空了(@RequestBody 先读过)
}

三种应对方式:

方式做法适用
只读一次,用对象共享用 @RequestBody 拿到对象,需要的地方传对象首选 ✓
ContentCachingRequestWrapper在 Filter 里包装一次,之后可反复读需要记日志/审计时 ✓
自己缓存(ThreadLocal 存一份)读一次存起来同上,但更手工 ⚠️

"需要反复读请求体"的典型场景:在 Filter 里记录"请求体日志"(审计、排查),但业务代码还要用 @RequestBody 解析 ✓

⚠️ 实践中要注意:把整个请求体缓存进内存是有代价的(大文件上传时可能 OOM)✗ 所以要设上限 ✓

6. 递进示例 ​

6.1 最小示例:一个能跑的 Servlet ​

java
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write("hello " + request.getParameter("name"));
    }
}

注册(Spring Boot 里):

java
@Bean
public ServletRegistrationBean<HelloServlet> hello() {
    return new ServletRegistrationBean<>(new HelloServlet(), "/hello");
}

6.2 业务示例:把请求信息全打出来 ​

见第 7 节。

6.3 反例:在 Servlet 里存可变状态 ​

java
public class BadServlet extends HttpServlet {
    private List<String> recentRequests = new ArrayList<>();       // ✗ 并发下会坏

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        recentRequests.add(request.getRequestURI());                // ✗ 多线程同时 add
        // ...
    }
}

问题:ArrayList 非线程安全——并发 add 可能导致数据丢失,甚至内部数组状态错乱 ✗

修复:

java
private final List<String> recentRequests = new CopyOnWriteArrayList<>();      // ✓
// 或
private final Deque<String> recentRequests = new ConcurrentLinkedDeque<>();    // ✓

更好的思路:这种"跨请求的状态"根本不该放在 Servlet 里(《Filter、Listener 与会话管理》 的会话管理、或独立的状态存储)✓

7. 完整可运行代码 ​

文件:labs/web-basics/src/main/java/cn/kb/learn/web/servlet/RawServlet.java

java
public class RawServlet extends HttpServlet {

    private static final long serialVersionUID = 1L;

    /** 统计访问次数:用来观察"Servlet 是单例、被多线程共享"这件事 */
    private final AtomicInteger visitCount = new AtomicInteger();

    @Override
    public void init() throws ServletException {
        System.out.println("[Servlet 生命周期] init() —— 容器启动时调用一次;此后这个实例被复用");
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        describe(request, response, null);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 读请求体:注意请求体只能读一次,读完后流就空了
        String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        describe(request, response, body);
    }
    // describe(...) 里逐项打印请求信息,并写回一个 JSON
}

运行命令:

powershell
cd labs
./mvnw -pl web-basics package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar web-basics/target/web-basics.jar

另一个终端发请求:

powershell
curl.exe -s "http://localhost:18090/raw/hello?name=zhangsan&age=18"
curl.exe -s -X POST "http://localhost:18090/raw/hello" -H "Content-Type: text/plain" -d "hello-raw-servlet"

预期输出(响应):

text
===== 1. GET /raw/hello =====
{
  "from": "raw servlet(没有经过 Spring MVC)",
  "method": "GET",
  "uri": "/raw/hello",
  "queryString": "name=zhangsan&age=18",
  "thread": "http-nio-18090-exec-1",
  "visitCount": 1,
  "body": null
}

===== 2. POST /raw/hello =====
{
  "from": "raw servlet(没有经过 Spring MVC)",
  "method": "POST",
  "uri": "/raw/hello",
  "queryString": "null",
  "thread": "http-nio-18090-exec-2",
  "visitCount": 2,
  "body": "hello-raw-servlet"
}

预期输出(应用日志):

text
[Servlet 生命周期] init() —— 容器启动时调用一次;此后这个实例被复用
[LoggingFilter] ── 前段:进入 GET /raw/hello(线程 http-nio-18090-exec-1)
===== 收到一个请求(第 1 次)=====
请求方法:GET
请求 URI :/raw/hello
查询串   :name=zhangsan&age=18
线程名   :http-nio-18090-exec-1(Tomcat 的工作线程池)
[LoggingFilter] ── 后段:结束 /raw/hello,耗时 22862 μs
[LoggingFilter] ── 前段:进入 POST /raw/hello(线程 http-nio-18090-exec-2)
===== 收到一个请求(第 2 次)=====
请求方法:POST
线程名   :http-nio-18090-exec-2(Tomcat 的工作线程池)

三处最值得盯住的:

观察说明了什么
init() 只出现一次生命周期:实例只有一个,被反复复用
"第 1 次"→"第 2 次",线程 exec-1→exec-2单例 + 多线程:同一个对象,不同线程 ✓
queryString 在 POST 里是 null查询串与请求体是两回事:POST 的参数在 body 里,不在 URL 上 ✓

8. Debug 观察点 ​

  1. 在 init() 处打断点:观察它只命中一次(哪怕你发 100 个请求);
  2. 在 describe 处打断点,同时在两个终端发请求:观察两个线程同时停在同一个对象的方法里(线程面板能看到两个栈)★
  3. 在 request.getInputStream() 处断点:观察请求体的流只能读一次;
  4. 把 visitCount 从 AtomicInteger 改成 int,用并发工具压一下(或用 《线程与同步基础》 的两个线程各发 1000 次):观察计数小于预期 ✗;
  5. 看 Tomcat 的线程名:http-nio-18090-exec-N——N 会复用(线程池),发很多请求后仍只有少数几个不同的 N ✓。

9. 常见错误 ​

错误 1:在 Servlet / Controller 里放可变实例字段 ​

见 5.4 与 6.3 节。这是 Web 开发里最经典的一类并发 bug ✗

排查方向:搜 Controller / Servlet 里的非 final 字段(或 final 但内容可变的) ✓

错误 2:以为"Servlet 只处理一个请求" ​

症状:把"当前请求的用户"存到实例字段里,结果用户 A 看到了用户 B 的数据 ✗✗(这是安全事故,不是普通 bug)

修复:"当前请求的数据"只能走方法参数或 ThreadLocal ✓

错误 3:不设 Content-Type,中文乱码 ​

java
response.getWriter().write("你好");           // ✗ 没设 Content-Type

症状:浏览器显示乱码 ✗

修复:

java
response.setContentType("application/json;charset=UTF-8");      // ✓

更稳的做法:在 Filter 里统一设(或让 Spring MVC 的 HttpMessageConverter 处理)✓

错误 4:想读两次请求体 ​

见 5.6 节。

错误 5:以为 HttpServletRequest 也能"复用" ​

事实:容器保证每个请求一个独立的 request 对象 ✓ ——所以你可以在方法里随便用它,不用怕并发 ✓

⚠️ 但这有个边界:异步处理(asyncSupported = true)时,请求可能在另一个线程里完成,此时 request 的可见性要小心 ⚠️

判断规则:默认情况下(同步处理)request/response 是安全的;一旦引入异步,就要重新审视 ✓

10. 动手实验 ​

目标:把"报文结构、生命周期、单例多线程"三件事亲手验证一遍。

步骤:

  1. 运行第 7 节的命令,核对响应与日志;
  2. 用 -v 看完整报文:
powershell
curl.exe -v "http://localhost:18090/raw/hello?name=zhangsan"
# 观察请求行、你发的头、Tomcat 回的响应头(Server、Content-Type、Content-Length)
  1. 验证 init() 只调一次:连发 5 个请求,在日志里数 init() 出现的次数;
  2. 验证单例:观察 visitCount 从 1 累加到 5(说明是同一个实例);
  3. 制造并发:用两个终端几乎同时发请求,观察日志里出现 exec-1 与 exec-2 交替(证明多线程);
  4. 验证"请求体只能读一次":加一个 Filter,在 doFilter 里先读一次 request.getInputStream(),再放行——观察 Servlet 里读到的是空 ✗(这正是 5.6 的现场)。

验收方式:能说出第 3 步 init() 的次数、第 5 步两个线程名的含义。

扩展任务:写一个"计数器 Servlet",用两种实现对比:

  • 方案 A:private int count = 0;(有 bug)
  • 方案 B:private final AtomicInteger count = new AtomicInteger();(正确)
  • 各用 10 个线程并发请求 1000 次(写个小脚本或用 《线程与同步基础》 的多线程客户端),对比最终计数 ✓

11. 练习题 ​

基础题

  1. 判断:Servlet 的 init() 方法在每个请求到来时都会执行一次。(对 / 错)
  2. 填空:HTTP 请求报文由 、、______ 三部分组成;Servlet 是 ______ 的(实例个数),处理请求时由容器分配 ______。
  3. 简答:为什么 HttpServletRequest 可以放心地在方法里使用,而 Servlet 的实例字段却要小心?

代码阅读题

java
public class CounterServlet extends HttpServlet {
    private int totalRequests = 0;
    private String lastUser = null;

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        totalRequests++;
        lastUser = request.getParameter("user");
        response.getWriter().write("总数:" + totalRequests + ",最后访问:" + lastUser);
    }
}
  1. 这段代码在并发下会有什么问题?请指出至少三处。
  2. 如果用户 A 与用户 B 同时访问,可能出现什么现象?(提示:考虑 lastUser)

编程题

  1. 写一个"请求信息回显"Servlet,满足:
    • 路径 /echo/*(用路径通配符,能接住任意子路径);
    • 支持 GET 与 POST:GET 时回显查询参数,POST 时回显请求体;
    • 回显内容包含:方法、完整 URL、所有请求头、Content-Type、字符集、客户端 IP(考虑 X-Forwarded-For);
    • 响应必须显式设置 Content-Type 与字符集,并实测中文不乱码;
    • 用 curl -v 对比"请求报文"与"你的回显"是否一致;
    • 回答:为什么 X-Forwarded-For 在有了反向代理(Nginx)之后才需要看?

综合任务

  1. 回答一个"面试常问但很少有人答透"的问题,并动手验证:
    • 问题:DispatcherServlet 和普通的 HttpServlet 是什么关系?一个 Spring MVC 应用里有几个 Servlet?
    • 要求:
      • 用代码或配置证明 DispatcherServlet 的存在:注册一个自己的 Servlet 到 /raw/*,再访问 /api/...(Spring MVC 的路径)——两者能同时工作,说明它们是不同的 Servlet;
      • 通过日志或断点确认 DispatcherServlet 的类名与它的 url-pattern;
      • 解释:"为什么 Spring MVC 只用一个 Servlet 就能路由到成百上千个方法?"(提示:对比普通 Servlet"一个类一组 URL"的做法);
      • 说明这种设计的好处与代价(提示:好处是统一入口→可以做统一的参数解析、异常处理、拦截器;代价是入口层成为热点,且配置不当会影响静态资源)。
分级提示

提示 1(方向):第 4 题的三处问题都和"字段被多线程共享"有关。

提示 2(关键点):第 5 题的 lastUser 是"两个请求相互覆盖"的典型。

提示 3(接近答案):第 7 题的"一个 Servlet 路由到上千方法",本质是把"URL 匹配"从容器层搬到了应用层。

12. 参考答案 ​

基础题
  1. 错。init() 只在容器启动(或首次访问)时调用一次 ✓

三个方法的调用次数:

方法次数
init()1 次
service() / doGet() / doPost()每个请求 1 次
destroy()1 次

⚠️ 一个例外:Servlet 被重新加载时(开发时热部署)会先 destroy 再 init ✓ 但生产中不会。

  1. 请求行、请求头、请求体(中间还有一个空行作为分隔);单例;一个工作线程。

补充:"空行"值得单独记——它是 HTTP 报文语法的一部分,不是排版 ✓ 少了它,解析会出错。

  1. 因为它们的生命周期不同。
HttpServletRequestServlet 的实例字段
谁创建容器容器(一次性)
存活多久一次请求整个应用生命周期
有几个每个请求一个 ✓只有一个 ✗
谁来访问只有处理这个请求的那个线程所有工作线程 ✗

一句话:

request 是"私有的、临时的"——天然没有并发问题;实例字段是"公共的、长期的"——必须自己保证线程安全。 ✓

这也解释了为什么"用方法参数传数据"是 Web 开发的默认姿势:参数是线程私有的 ✓

代码阅读题(第 4、5 题)

第 4 题:三处问题。

#问题说明
1totalRequests++ 不是原子操作它包含"读-改-写"三步,并发下会丢更新 ✗(《线程与同步基础》 的 count++ 问题)
2lastUser 是普通字段,多线程读写无同步可见性问题:线程 A 写了,线程 B 可能读不到(或读到中间状态)✗
3两个字段的更新不是一个原子操作可能出现"totalRequests 已经是 5,但 lastUser 还是第 3 个请求的用户"——数据不自洽 ⚠️

还有一处"设计问题":

#问题
4把"最后一次访问"存在 Servlet 里,本身就是错的——它不是"服务端的状态",而是"请求的副产品" ✗

第 5 题:用户 A 可能看到用户 B 的名字。

具体的竞态序列:

text
时刻 ①  线程 A:lastUser = "Alice"
时刻 ②  线程 B:lastUser = "Bob"
时刻 ③  线程 A:读取 lastUser 拼响应 → 读到 "Bob" ✗
        结果:A 的响应里显示"最后访问:Bob"

更糟的情况(如果响应是"当前用户"的语义):

java
response.getWriter().write("当前用户:" + lastUser);

这会变成"用户 A 看到了用户 B 的信息" ✗✗ ——这不是显示 bug,这是越权/信息泄露 ✓

正确的实现(把"当前请求的数据"放局部变量):

java
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
    int current = totalRequests.incrementAndGet();          // ✓ 原子递增
    String currentUser = request.getParameter("user");      // ✓ 局部变量,线程私有
    response.setContentType("text/plain;charset=UTF-8");
    response.getWriter().write("总数:" + current + ",本次访问:" + currentUser);
}

这个题的核心教训:

"跨请求的共享状态"与"当前请求的数据"是两类东西,绝不该放在同一个地方。

  • 跨请求共享的统计(如访问总数)→ 用线程安全的容器,或交给监控系统(《日志框架与日志规范》)✓
  • 当前请求的数据(如当前用户)→ 只能用方法参数或 ThreadLocal ✓

把它们混在一起的代码,往往在测试时完全正常(单线程),上线后才出问题 ✗

编程题(第 6 题)
java
package cn.kb.learn.web.servlet;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

/** 请求信息回显:把"容器看到的请求"完整呈现出来 */
public class EchoServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        echo(request, response, null);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        echo(request, response, body);
    }

    private void echo(HttpServletRequest request, HttpServletResponse response, String body) throws IOException {
        Map<String, String> headers = new LinkedHashMap<>();
        for (String name : Collections.list(request.getHeaderNames())) {
            headers.put(name, request.getHeader(name));
        }

        Map<String, String> params = new LinkedHashMap<>();
        for (String name : Collections.list(request.getParameterNames())) {
            params.put(name, request.getParameter(name));
        }

        String json = """
                {
                  "method": "%s",
                  "url": "%s",
                  "queryString": %s,
                  "pathInfo": %s,
                  "contentType": %s,
                  "charset": %s,
                  "clientIp": "%s",
                  "clientIpFromProxy": %s,
                  "headers": %s,
                  "queryParams": %s,
                  "body": %s
                }
                """.formatted(
                request.getMethod(),
                request.getRequestURL(),
                quote(request.getQueryString()),
                quote(request.getPathInfo()),
                quote(request.getContentType()),
                quote(request.getCharacterEncoding()),
                request.getRemoteAddr(),
                quote(request.getHeader("X-Forwarded-For")),
                toJson(headers),
                toJson(params),
                quote(body));

        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json;charset=UTF-8");     // ★ 显式带 charset
        try (PrintWriter writer = response.getWriter()) {
            writer.print(json);
        }
    }

    private static String quote(String value) {
        return value == null ? "null" : "\"" + value.replace("\"", "\\\"") + "\"";
    }

    private static String toJson(Map<String, String> map) {
        StringBuilder builder = new StringBuilder("{");
        map.forEach((key, value) -> builder.append(quote(key)).append(":")
                .append(quote(value)).append(","));
        if (builder.length() > 1) {
            builder.setLength(builder.length() - 1);
        }
        return builder.append("}").toString();
    }
}

注册(可以复用到 5.2 节的写法):

java
@Bean
public ServletRegistrationBean<EchoServlet> echoServlet() {
    return new ServletRegistrationBean<>(new EchoServlet(), "/echo/*");
}

实测命令:

powershell
curl.exe -s "http://localhost:18090/echo/detail?user=alice&tag=a&tag=b"
curl.exe -s -X POST "http://localhost:18090/echo/detail" -H "Content-Type: application/json;charset=UTF-8" -d "{\"msg\":\"hi\"}"
curl.exe -v "http://localhost:18090/echo/detail?user=alice"           # 对比请求报文

为什么 X-Forwarded-For 在有了反向代理之后才需要看:

部署形态getRemoteAddr() 返回什么
客户端直连 Tomcat真实客户端 IP ✓
Nginx → TomcatNginx 的 IP ✗(因为 TCP 连接是 Nginx 发起的)

代理的工作方式:

text
浏览器 → Nginx → Tomcat
          ↑ 这里建立了新的 TCP 连接
          → Tomcat 看到的"远端地址"是 Nginx

所以 Nginx 会把"原客户端 IP"写进 X-Forwarded-For 头:

nginx
location / {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://tomcat;
}

⚠️ 三个必须注意的问题:

问题说明
X-Forwarded-For 可以被伪造 ✗客户端自己就能发这个头——只有"可信代理"发来的才可信 ✓
它是一串 IP格式:client, proxy1, proxy2——通常取第一个,但要配合 X-Forwarded-For 的信任链判断
安全后果如果用它做"IP 白名单"或"限流",伪造头就能绕过 ✗✗

正确的做法:

java
// 只在"请求来自可信代理"时才采信 X-Forwarded-For
private String clientIp(HttpServletRequest request) {
    String remote = request.getRemoteAddr();
    if (!TRUSTED_PROXIES.contains(remote)) {
        return remote;                       // 不是可信代理发来的 → 用真实远端地址 ✓
    }
    String forwarded = request.getHeader("X-Forwarded-For");
    if (forwarded == null || forwarded.isBlank()) {
        return remote;
    }
    return forwarded.split(",")[0].trim();   // 取最左边那个(原始客户端)
}

Spring Boot 里的开箱做法:server.forward-headers-strategy=framework 会自动处理这些头(但前提仍是"前面确实是可信代理")✓

综合任务(第 7 题)

核心结论:一个 Spring MVC 应用里有两个 Servlet。

Servleturl-pattern谁注册的
DispatcherServlet/(除 JSP 与默认 Servlet 之外的全部路径)Spring Boot 自动配置(《Spring Boot 自动配置与项目结构》)
default(DefaultServlet)/(兜底,处理静态资源)Tomcat 内置 ⚠️
我们注册的 RawServlet/raw/*本项目显式注册

"能同时工作"的证明:

text
GET /raw/hello        → 由 RawServlet 处理(返回 "from": "raw servlet...")✓
GET /api/orders/ORD-1 → 由 DispatcherServlet 处理(返回订单 JSON)✓

两者互不干扰,因为容器按 URL 匹配规则分派——/raw/* 是更具体的模式,优先匹配 ✓

怎么确认 DispatcherServlet 的存在:

方法怎么做
看自动配置的 Bean《Spring Boot 自动配置与项目结构》 的 StartupReporter 里就检查了 DispatcherServlet → 1 个 ✓
打断点在 DispatcherServlet.doDispatch 打断点,访问任意 @RestController 路径会命中
看日志启动日志里 Initializing Servlet 'dispatcherServlet'
故意制造异常访问一个不存在的 /api/xxx,看到 Spring MVC 风格的 404(说明是它处理的)

"为什么一个 Servlet 能路由到上千个方法":

普通 Servlet 的模型:

text
一个类 → 一组 URL(在容器里注册映射)
10 个业务模块 → 10 个 Servlet(或一个巨大的 if-else)

Spring MVC 的模型:

text
所有 URL → 唯一入口 DispatcherServlet
               ↓ 它内部维护一张"路由表"(HandlerMapping)
           扫描所有 @RequestMapping,建成 Map<URL 模式, 方法>
               ↓ 请求来了查表
           反射调用对应的 Controller 方法

本质:把"URL 匹配"这件事从容器层搬到了应用层 ✓

好处与代价:

好处说明
统一入口参数解析、数据绑定、校验、异常处理、拦截器、内容协商——都在一处实现,所有接口共享 ✓
路由可以很灵活支持路径变量(/{id})、通配、正则、内容协商(produces)——容器不支持这些 ✓
配置即代码不用写 web.xml,注解一贴就有路由 ✓
可测试不需要真实容器也能测(T1-016)✓
代价说明
入口层是热点所有请求都过它——但它很薄,通常不是瓶颈(真瓶颈在数据库)
配置不当会影响静态资源如果 DispatcherServlet 映射到 / 而没配静态资源处理,静态文件会 404 ⚠️(Spring Boot 已经帮你配好了)
抽象层数多出问题时调用栈很深(这是"理解底层"的价值所在)

一句话总结:

Servlet 是"容器给我的一个请求";Spring MVC 是"在这个请求之上,用一套统一规则把它分派到某个方法"。

前者解决"怎么收到请求",后者解决"怎么组织成千上百个处理逻辑"。 ✓

13. 掌握标准 ​

  • [ ] 能画出 HTTP 请求/响应报文的三部分结构(含"空行"的位置);
  • [ ] 能说出 Servlet 三个生命周期方法的调用次数;
  • [ ] 知道 Servlet 是单例、多线程共享,并知道哪些字段该放、哪些不该放;
  • [ ] 能用 HttpServletRequest 取方法、URI、头、参数、请求体;
  • [ ] 会显式设置 Content-Type 的 charset(避免中文乱码);
  • [ ] 知道 DispatcherServlet 就是一个 Servlet,并知道它与普通 Servlet 的对照关系;
  • [ ] 知道请求体只能读一次,以及三种应对方式;
  • [ ] 知道 X-Forwarded-For 只在有可信代理时才该采信。

14. 扩展资源 ​

15. 下一步学习 ​

《Filter、Listener 与会话管理》:请求进到 Servlet 之前,还会经过谁。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引