主题
界面
主题
前面写过很多 @RestController(《Spring MVC 请求映射与参数绑定》),本节要往下挖一层:
你写的: @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 本来就是单例多线程 |
完成本节后,你应该能够:
HttpServletRequest 取出方法、URI、头、参数、请求体;HttpServletResponse 写状态码、响应头、响应体;DispatcherServlet 与普通 Servlet 的关系;InputStream / Reader)吗?(《泛型基础》)说明:先修章节 《HTTP 与 HTTPS》(网络基础)尚未产出。本节会在 5.1 节自带必要的 HTTP 报文知识,所以即使没学过网络基础也能跟上。
先看一个真实的困惑:
@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 能"记住当前请求" | 请求处理在一个线程里完成(但异步时会变) |
请求报文(三部分):
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} ← ④ 请求体(可选)响应报文:
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 与会话管理》)✓
Servlet 是 Java 对"处理 HTTP 请求"这件事的抽象:
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。
public class RawServlet extends HttpServlet {
@Override
public void init() throws ServletException {
// ① 容器启动(或首次访问)时调用一次
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
// ② 每个 GET 请求调用一次(并发请求会在不同线程里同时进入)
}
@Override
public void destroy() {
// ③ 容器关闭时调用一次
}
}实测输出(本项目启动后的日志):
[Servlet 生命周期] init() —— 容器启动时调用一次;此后这个实例被复用init() 只出现一次(尽管后面处理了 4 个请求)✓
| 方法 | 调用次数 | 谁调用 |
|---|---|---|
init() | 一次 | 容器 |
service() → doGet / doPost | 每个请求一次 | 容器(在工作线程里) |
destroy() | 一次 | 容器(关闭时) |
这三条约束推出了两个重要结论:
| 结论 | 原因 |
|---|---|
init() 里的东西是"全局共享"的 | 只执行一次 → 适合做"初始化",不适合放请求相关的状态 |
doGet 里的字段会被并发访问 | 多线程同时进入同一对象 ✗ |
实测证据:同一张 Servlet 上有两个请求,访问计数是累加的:
===== 收到一个请求(第 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 ✓
private final AtomicInteger visitCount = new AtomicInteger(); // ✓ 线程安全
// private int visitCount = 0; // ✗ 并发下会丢更新这就是 《线程与同步基础》 讲过的内容在 Web 场景的落地 ✓
Servet 里"什么是安全的、什么是危险的":
| 放什么 | 安全吗 | 原因 |
|---|---|---|
final 的、不可变的对象(如配置、ObjectMapper) | ✅ 安全 | 多线程只读 ✓ |
AtomicInteger / ConcurrentHashMap 等并发容器 | ✅ 安全 | 有内存可见性与原子性保证 ✓ |
| 普通可变的实例字段 | ❌ 危险 | 多线程读写会互相干扰 ✗ |
HttpServletRequest / HttpServletResponse | ✅ 安全 | 容器保证"每个请求一个独立对象" ✓ |
| ThreadLocal | ⚠️ 谨慎 | 用对了很方便(如 MDC),用错了会泄漏(《日志框架与日志规范》 讲过) |
一句可以直接用的规则:
Servlet / Controller / Service 里的实例字段,要么是不可变的,要么是线程安全的容器——否则就别写。
所有"每个请求都不一样"的数据,一律用方法参数(或 ThreadLocal)传递,不要放字段。 ✓
DispatcherServlet 就是一个 Servlet Spring MVC 并没有绕开 Servlet 规范,它只是注册了一个"总入口 Servlet":
所有请求 / → DispatcherServlet(url-pattern 通常是 "/")
↓ 它内部再做"路由到具体方法"的事
HandlerMapping → Controller 方法对照表:
| Servlet 世界的概念 | Spring MVC 里的对应 |
|---|---|
Servlet | DispatcherServlet(唯一一个) |
url-pattern | @RequestMapping(它内部的路由表) |
doGet / doPost | @GetMapping / @PostMapping |
从 request.getParameter() 取值 | @RequestParam / @PathVariable |
自己往 response 写 | 返回值 + HttpMessageConverter |
web.xml 里配置 | 自动配置(《Spring Boot 自动配置与项目结构》) |
这张表的价值:你在 Spring MVC 里遇到的"底层问题",都能在这张表里找到归处 ✓
| 现象 | 对照到 Servlet 世界 |
|---|---|
| 静态资源 404 | DispatcherServlet 的 url-pattern 与静态资源处理的冲突 ⚠️ |
| 请求体读两次报错 | Servlet 的 InputStream 只能读一次(5.6) |
| 上传大文件失败 | MultipartResolver 的配置(底层是 Servlet 的 multipart 支持) |
| 中文乱码 | request.setCharacterEncoding / Content-Type 的 charset |
在 Servlet 层面,请求体就是一个 InputStream:
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);读完之后流指针到末尾——再读就是空的 ✗
这就解释了 Spring MVC 里那条限制(《Spring MVC 请求映射与参数绑定》 的 5.3 节):
public void create(@RequestBody CreateOrderRequest request, HttpServletRequest raw) {
raw.getInputStream().readAllBytes(); // ✗ 已经空了(@RequestBody 先读过)
}三种应对方式:
| 方式 | 做法 | 适用 |
|---|---|---|
| 只读一次,用对象共享 | 用 @RequestBody 拿到对象,需要的地方传对象 | 首选 ✓ |
ContentCachingRequestWrapper | 在 Filter 里包装一次,之后可反复读 | 需要记日志/审计时 ✓ |
| 自己缓存(ThreadLocal 存一份) | 读一次存起来 | 同上,但更手工 ⚠️ |
"需要反复读请求体"的典型场景:在 Filter 里记录"请求体日志"(审计、排查),但业务代码还要用 @RequestBody 解析 ✓
⚠️ 实践中要注意:把整个请求体缓存进内存是有代价的(大文件上传时可能 OOM)✗ 所以要设上限 ✓
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 里):
@Bean
public ServletRegistrationBean<HelloServlet> hello() {
return new ServletRegistrationBean<>(new HelloServlet(), "/hello");
}见第 7 节。
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 可能导致数据丢失,甚至内部数组状态错乱 ✗
修复:
private final List<String> recentRequests = new CopyOnWriteArrayList<>(); // ✓
// 或
private final Deque<String> recentRequests = new ConcurrentLinkedDeque<>(); // ✓更好的思路:这种"跨请求的状态"根本不该放在 Servlet 里(《Filter、Listener 与会话管理》 的会话管理、或独立的状态存储)✓
文件:labs/web-basics/src/main/java/cn/kb/learn/web/servlet/RawServlet.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
}运行命令:
cd labs
./mvnw -pl web-basics package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar web-basics/target/web-basics.jar另一个终端发请求:
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"预期输出(响应):
===== 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"
}预期输出(应用日志):
[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 上 ✓ |
init() 处打断点:观察它只命中一次(哪怕你发 100 个请求);describe 处打断点,同时在两个终端发请求:观察两个线程同时停在同一个对象的方法里(线程面板能看到两个栈)★request.getInputStream() 处断点:观察请求体的流只能读一次;visitCount 从 AtomicInteger 改成 int,用并发工具压一下(或用 《线程与同步基础》 的两个线程各发 1000 次):观察计数小于预期 ✗;http-nio-18090-exec-N——N 会复用(线程池),发很多请求后仍只有少数几个不同的 N ✓。见 5.4 与 6.3 节。这是 Web 开发里最经典的一类并发 bug ✗
排查方向:搜 Controller / Servlet 里的非 final 字段(或 final 但内容可变的) ✓
症状:把"当前请求的用户"存到实例字段里,结果用户 A 看到了用户 B 的数据 ✗✗(这是安全事故,不是普通 bug)
修复:"当前请求的数据"只能走方法参数或 ThreadLocal ✓
Content-Type,中文乱码 response.getWriter().write("你好"); // ✗ 没设 Content-Type症状:浏览器显示乱码 ✗
修复:
response.setContentType("application/json;charset=UTF-8"); // ✓更稳的做法:在 Filter 里统一设(或让 Spring MVC 的 HttpMessageConverter 处理)✓
见 5.6 节。
HttpServletRequest 也能"复用" 事实:容器保证每个请求一个独立的 request 对象 ✓ ——所以你可以在方法里随便用它,不用怕并发 ✓
⚠️ 但这有个边界:异步处理(asyncSupported = true)时,请求可能在另一个线程里完成,此时 request 的可见性要小心 ⚠️
判断规则:默认情况下(同步处理)request/response 是安全的;一旦引入异步,就要重新审视 ✓
目标:把"报文结构、生命周期、单例多线程"三件事亲手验证一遍。
步骤:
-v 看完整报文:curl.exe -v "http://localhost:18090/raw/hello?name=zhangsan"
# 观察请求行、你发的头、Tomcat 回的响应头(Server、Content-Type、Content-Length)init() 只调一次:连发 5 个请求,在日志里数 init() 出现的次数;visitCount 从 1 累加到 5(说明是同一个实例);exec-1 与 exec-2 交替(证明多线程);doFilter 里先读一次 request.getInputStream(),再放行——观察 Servlet 里读到的是空 ✗(这正是 5.6 的现场)。验收方式:能说出第 3 步 init() 的次数、第 5 步两个线程名的含义。
扩展任务:写一个"计数器 Servlet",用两种实现对比:
private int count = 0;(有 bug)private final AtomicInteger count = new AtomicInteger();(正确)基础题
init() 方法在每个请求到来时都会执行一次。(对 / 错)HttpServletRequest 可以放心地在方法里使用,而 Servlet 的实例字段却要小心?代码阅读题
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);
}
}lastUser)编程题
/echo/*(用路径通配符,能接住任意子路径);Content-Type、字符集、客户端 IP(考虑 X-Forwarded-For);Content-Type 与字符集,并实测中文不乱码;curl -v 对比"请求报文"与"你的回显"是否一致;X-Forwarded-For 在有了反向代理(Nginx)之后才需要看?综合任务
DispatcherServlet 和普通的 HttpServlet 是什么关系?一个 Spring MVC 应用里有几个 Servlet?DispatcherServlet 的存在:注册一个自己的 Servlet 到 /raw/*,再访问 /api/...(Spring MVC 的路径)——两者能同时工作,说明它们是不同的 Servlet;DispatcherServlet 的类名与它的 url-pattern;提示 1(方向):第 4 题的三处问题都和"字段被多线程共享"有关。
提示 2(关键点):第 5 题的 lastUser 是"两个请求相互覆盖"的典型。
提示 3(接近答案):第 7 题的"一个 Servlet 路由到上千方法",本质是把"URL 匹配"从容器层搬到了应用层。
init() 只在容器启动(或首次访问)时调用一次 ✓三个方法的调用次数:
| 方法 | 次数 |
|---|---|
init() | 1 次 |
service() / doGet() / doPost() | 每个请求 1 次 |
destroy() | 1 次 |
⚠️ 一个例外:Servlet 被重新加载时(开发时热部署)会先 destroy 再 init ✓ 但生产中不会。
补充:"空行"值得单独记——它是 HTTP 报文语法的一部分,不是排版 ✓ 少了它,解析会出错。
HttpServletRequest | Servlet 的实例字段 | |
|---|---|---|
| 谁创建 | 容器 | 容器(一次性) |
| 存活多久 | 一次请求 | 整个应用生命周期 |
| 有几个 | 每个请求一个 ✓ | 只有一个 ✗ |
| 谁来访问 | 只有处理这个请求的那个线程 | 所有工作线程 ✗ |
一句话:
request是"私有的、临时的"——天然没有并发问题;实例字段是"公共的、长期的"——必须自己保证线程安全。 ✓
这也解释了为什么"用方法参数传数据"是 Web 开发的默认姿势:参数是线程私有的 ✓
第 4 题:三处问题。
| # | 问题 | 说明 |
|---|---|---|
| 1 | totalRequests++ 不是原子操作 | 它包含"读-改-写"三步,并发下会丢更新 ✗(《线程与同步基础》 的 count++ 问题) |
| 2 | lastUser 是普通字段,多线程读写无同步 | 可见性问题:线程 A 写了,线程 B 可能读不到(或读到中间状态)✗ |
| 3 | 两个字段的更新不是一个原子操作 | 可能出现"totalRequests 已经是 5,但 lastUser 还是第 3 个请求的用户"——数据不自洽 ⚠️ |
还有一处"设计问题":
| # | 问题 |
|---|---|
| 4 | 把"最后一次访问"存在 Servlet 里,本身就是错的——它不是"服务端的状态",而是"请求的副产品" ✗ |
第 5 题:用户 A 可能看到用户 B 的名字。
具体的竞态序列:
时刻 ① 线程 A:lastUser = "Alice"
时刻 ② 线程 B:lastUser = "Bob"
时刻 ③ 线程 A:读取 lastUser 拼响应 → 读到 "Bob" ✗
结果:A 的响应里显示"最后访问:Bob"更糟的情况(如果响应是"当前用户"的语义):
response.getWriter().write("当前用户:" + lastUser);这会变成"用户 A 看到了用户 B 的信息" ✗✗ ——这不是显示 bug,这是越权/信息泄露 ✓
正确的实现(把"当前请求的数据"放局部变量):
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 ✓
把它们混在一起的代码,往往在测试时完全正常(单线程),上线后才出问题 ✗
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 节的写法):
@Bean
public ServletRegistrationBean<EchoServlet> echoServlet() {
return new ServletRegistrationBean<>(new EchoServlet(), "/echo/*");
}实测命令:
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 → Tomcat | Nginx 的 IP ✗(因为 TCP 连接是 Nginx 发起的) |
代理的工作方式:
浏览器 → Nginx → Tomcat
↑ 这里建立了新的 TCP 连接
→ Tomcat 看到的"远端地址"是 Nginx所以 Nginx 会把"原客户端 IP"写进 X-Forwarded-For 头:
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 白名单"或"限流",伪造头就能绕过 ✗✗ |
正确的做法:
// 只在"请求来自可信代理"时才采信 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 会自动处理这些头(但前提仍是"前面确实是可信代理")✓
核心结论:一个 Spring MVC 应用里有两个 Servlet。
| Servlet | url-pattern | 谁注册的 |
|---|---|---|
DispatcherServlet | /(除 JSP 与默认 Servlet 之外的全部路径) | Spring Boot 自动配置(《Spring Boot 自动配置与项目结构》) |
default(DefaultServlet) | /(兜底,处理静态资源) | Tomcat 内置 ⚠️ |
我们注册的 RawServlet | /raw/* | 本项目显式注册 |
"能同时工作"的证明:
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 的模型:
一个类 → 一组 URL(在容器里注册映射)
10 个业务模块 → 10 个 Servlet(或一个巨大的 if-else)Spring MVC 的模型:
所有 URL → 唯一入口 DispatcherServlet
↓ 它内部维护一张"路由表"(HandlerMapping)
扫描所有 @RequestMapping,建成 Map<URL 模式, 方法>
↓ 请求来了查表
反射调用对应的 Controller 方法本质:把"URL 匹配"这件事从容器层搬到了应用层 ✓
好处与代价:
| 好处 | 说明 |
|---|---|
| 统一入口 | 参数解析、数据绑定、校验、异常处理、拦截器、内容协商——都在一处实现,所有接口共享 ✓ |
| 路由可以很灵活 | 支持路径变量(/{id})、通配、正则、内容协商(produces)——容器不支持这些 ✓ |
| 配置即代码 | 不用写 web.xml,注解一贴就有路由 ✓ |
| 可测试 | 不需要真实容器也能测(T1-016)✓ |
| 代价 | 说明 |
|---|---|
| 入口层是热点 | 所有请求都过它——但它很薄,通常不是瓶颈(真瓶颈在数据库) |
| 配置不当会影响静态资源 | 如果 DispatcherServlet 映射到 / 而没配静态资源处理,静态文件会 404 ⚠️(Spring Boot 已经帮你配好了) |
| 抽象层数多 | 出问题时调用栈很深(这是"理解底层"的价值所在) |
一句话总结:
Servlet 是"容器给我的一个请求";Spring MVC 是"在这个请求之上,用一套统一规则把它分派到某个方法"。
前者解决"怎么收到请求",后者解决"怎么组织成千上百个处理逻辑"。 ✓
HttpServletRequest 取方法、URI、头、参数、请求体;Content-Type 的 charset(避免中文乱码);DispatcherServlet 就是一个 Servlet,并知道它与普通 Servlet 的对照关系;X-Forwarded-For 只在有可信代理时才该采信。《Filter、Listener 与会话管理》:请求进到 Servlet 之前,还会经过谁。