Old-School Backend / 2026 ・ No.03
古法后端入门
第3讲 徒手接收 HTTP ── ServerSocket、线程、JDK 的 HttpServer
发布
第 1 讲用 Spring Boot 写了申请 API,用一个小小的“窗口”接收申请。可是在它下面接收、读取、回复 HTTP 的,并不是我们自己的代码。
这次不用 Spring,只用 JDK 接收 HTTP:用 ServerSocket 一行一行地读,数 Content-Length,分配线程,手工读 JSON。徒手做一遍,实测会在哪里划破手。
徒手接收
新井Spring Boot 写一行 @PostMapping 就能接收,为什么还要特意徒手?
古井不知道那一行下面发生了什么,等生产环境有人说“偶尔有一件在中途断掉”的时候,你就不知道该看哪里。
新井真会发生那种事吗?
古井今天就亲手让它发生。
全 15 讲路线图(当前:第 3 讲)
- 一行一行地读 ── HTTP 是文字行。到空行为止是首部,之后按 Content-Length 读正文(实测)
- 长度按字节 ── 按字符数写长度,回复在 56 字节处断掉(实测与 Wireshark)
- 线程 ── 一个一个处理时 50 件要 5.26 秒,每人一个线程只要 0.2 秒。一个沉默的客人就能卡住所有人(实测)
- 手工读 JSON ── 引号、\u 转义、嵌套,让读到的值出错(实测)
- JDK 的 HttpServer ── 31 行,还会帮你返回 400。但不会帮你读 JSON(实测)
舞台还是之前的实验网络。从 PC(192.168.1.10)向申请服务器(192.168.1.20,名字是 expenses.example)的 8080 端口发送申请。服务器程序是只用 JDK 25 写的 RawServer.java(不计空行 86 行)和 JdkServer.java(同样 31 行),一个库都没用。
用 ServerSocket 一行一行地读
正如网络篇第 10 讲所见,HTTP 请求只是文字行。所以服务器只要读行就能接收。先看接收部分的代码。
String first = readLine(in); // 例:POST /api/expenses HTTP/1.1
if (first == null) { log("何も来ないまま切れた"); return; }
log("1行目 " + first);
Map<String, String> headers = new LinkedHashMap<>();
for (String line; (line = readLine(in)) != null && !line.isEmpty(); ) { // 直到空行
int c = line.indexOf(':');
headers.put(line.substring(0, c).trim().toLowerCase(), line.substring(c + 1).trim());
}
log("ヘッダー " + headers.size() + " 行");
int len = Integer.parseInt(headers.getOrDefault("content-length", "0"));
String body = new String(in.readNBytes(len), StandardCharsets.UTF_8);
log("本文 " + len + " バイト " + body);
读第 1 行,一行一行读首部直到出现空行,最后按 Content-Length 写的数量读取正文字节。从 PC 用 curl 发了一件申请。
# 从 PC 用 curl -v 提交一件申请(> 是发出的行,< 是收到的行)
> POST /api/expenses HTTP/1.1
> Host: expenses.example:8080
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 54
>
} [54 bytes data]
< HTTP/1.1 201 Created
< Content-Type: application/json
< Content-Length: 62
< Location: /api/expenses/EXP-2026-0012
< Connection: close
<
{ [62 bytes data]
{"id":"EXP-2026-0012","item":"交通費","status":"submitted"}
# 同一时刻,申请服务器(RawServer)的日志
01:11:52.825 main 待ち受け開始 0.0.0.0/0.0.0.0:8080 threads=single
01:11:55.189 main 接続 /192.168.1.10:34696
01:11:55.191 main 1行目 POST /api/expenses HTTP/1.1
01:11:55.196 main ヘッダー 5 行
01:11:55.204 main 本文 54 バイト {"date":"2026-10-26","item":"交通費","amount":1280}
01:11:55.221 main 読み取った値 {date=2026-10-26, item=交通費, amount=1280}
01:11:55.267 main 201 Created を返した(Content-Length: 62、本文 62 バイト)
服务器这样读 HTTP 请求
行只要读到换行(CRLF)就能分开。首部的结尾是“什么都没写的一行”。可是正文没有结束标记。正文里出现换行也没关系,所以不能用换行来分隔。因此发送方要先在首部告诉对方正文有几个字节,接收方就读这么多。这里一旦弄错,就会像下一章那样。
长度按字节,不按字符
这是写回复的部分。把正文变成字节,把它的长度写进 Content-Length。为了比较,还准备了一个不小心写成“字符数”的版本(-Dbug.cl=true)。
byte[] b = json.getBytes(StandardCharsets.UTF_8); int cl = BUG_CL ? json.length() : b.length; // 有 bug 的版本把“字符数”当成长度 String head = "HTTP/1.1 " + status + "\r\nContent-Type: application/json\r\nContent-Length: " + cl + "\r\n" + extra + "Connection: close\r\n\r\n"; out.write(head.getBytes(StandardCharsets.US_ASCII)); out.write(b); out.flush();
# -Dbug.cl=true:用“字符数”计算 Content-Length 的版本
01:11:58.764 main 201 Created を返した(Content-Length: 56、本文 62 バイト)
# curl -v 收到的内容
< HTTP/1.1 201 Created
< Content-Type: application/json
< Content-Length: 56
< Location: /api/expenses/EXP-2026-0012
< Connection: close
<
{ [62 bytes data]
* Excess found writing body: excess = 6, size = 56, maxdownload = 56, bytecount = 56
{"id":"EXP-2026-0012","item":"交通費","status":"submi
56 个字符、62 字节。按字符写长度,正文就会被截断
服务器把 62 字节全部发出去了。可是 curl 说“听说 Content-Length 是 56,却多了 6 字节”,读到 56 字节就停下,正文在 "submi 处断掉。正如第 2 讲所见,“交通費”是 3 个字符、9 个字节。相差的 6 字节就这样溢出了。
用 Wireshark 看,线上确实载着 62 字节。即便如此,作为 HTTP 被读取的只有 56 字节。线上是对的,约定(首部)却错了,接收方相信的是约定。这次每次交互都会关闭连接(Connection: close),所以溢出的 6 字节只是被丢弃。如果一条连接被重复使用,这 6 字节就会被当作“下一个回复的开头”读取,连下一个回复也一起坏掉。项目只用英文和数字测试的话,这个 bug 一次都不会出现。
新井这就是“偶尔有一件在中途断掉”……只在项目是日文的时候。
古井就是这样。古法的现场管这叫“只有全角的时候会乱”。
一个一个来,沉默的客人会卡住所有人
接下来是很多客人同时来的情况。最直白的写法是:接待一个客人(accept),陪到这个客人结束,再接待下一个。
ExecutorService pool = switch (mode) {
case "platform" -> Executors.newCachedThreadPool();
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
default -> null;
};
try (var server = new ServerSocket(Integer.parseInt(args[0]))) {
log("待ち受け開始 " + server.getLocalSocketAddress() + " threads=" + mode);
while (true) {
Socket s = server.accept(); // 在这里停住,等到有人来
if (pool == null) handle(s); // 一个一个按顺序
else pool.submit(() -> handle(s)); // 每来一个人,交给另一个工人
}
}
用 -Dthreads=single 运行时,pool 是 null,所以当场调用 handle(s)。先放一个只连接、什么都不说的客人,再用 curl 申请。
curl 等了 3 秒,一个字节都没收到就放弃了。服务器日志里只有沉默客人的一行“连接”。服务器一直在用 readLine 等那个客人的第 1 行,轮不到下一个客人。curl 的连接本身已经由 OS 接受了(网络篇里看过的三次握手已经完成),所以在 curl 看来是“连上了却没有回复”。
即使不是恶意的客人,信号不好的手机、中途卡住的程序也会这样。还有故意慢慢发送来拖垮服务器的攻击,真正的服务器都有在一定时间后切断沉默客人的机制。
线程与虚拟线程
常规的解决办法,是给每个来的客人配一个单独的工人(线程)。-Dthreads=platform 每人配一个 OS 线程,-Dthreads=virtual 每人配一个 Java 21 起可用的虚拟线程。每件加入 100 毫秒“假装在干活”,从 PC 同时发送 50 件。
# 从 PC 同时发送 50 件。每件有 100 ms 的工作(-Dwork=100)
single {201: 50} total 5.261 s slowest 5.194 s fastest 0.151 s 工人 1
platform {201: 50} total 0.196 s slowest 0.179 s fastest 0.120 s 工人 50
virtual {201: 50} total 0.225 s slowest 0.214 s fastest 0.125 s 工人 50
同时 50 件:一个一个 与 每人一个(实测)
一个一个处理要 5.261 秒。100 毫秒 × 50 件,原样排成了长队。每人一个线程只要 0.2 秒左右,数日志里的线程名,有 50 个工人。用虚拟线程再放一次刚才那个沉默的客人。
# 虚拟线程版,有沉默的客人时用 curl
{"id":"EXP-2026-0012","item":"交通費","status":"submitted"}
-> 201 (0.026412 s)
# 服务器日志(virtual-22 是沉默的客人,virtual-29 是 curl)
01:12:26.955 virtual-22 接続 /192.168.1.10:33352
01:12:27.436 virtual-29 接続 /192.168.1.10:33362
01:12:27.438 virtual-29 1行目 POST /api/expenses HTTP/1.1
01:12:27.440 virtual-29 ヘッダー 5 行
01:12:27.443 virtual-29 本文 54 バイト {"date":"2026-10-26","item":"交通費","amount":1280}
01:12:27.446 virtual-29 読み取った値 {date=2026-10-26, item=交通費, amount=1280}
01:12:27.464 virtual-29 201 Created を返した(Content-Length: 62、本文 62 バイト)
沉默的客人(virtual-22)继续等着,curl 的申请(virtual-29)0.026 秒就拿到了 201。
OS 线程每个都要分配内存,不适合几千、几万个客人。虚拟线程在等待时会让出 OS 线程,可以便宜地容纳大量“在等的客人”。不过,沉默的客人并不会因此消失。只要客人不说话,连接和虚拟线程都会一直留着。给等待时间加上上限(超时),哪种方式都需要。第 1 讲的 Tomcat 用 OS 线程池(最多 200 个)来接待客人,在 Spring Boot 中只要一个设置(spring.threads.virtual.enabled=true)就能换成虚拟线程。
手工读 JSON 的陷阱
正文的 JSON 用正则表达式“像模像样”地读。只是按顺序捡出 "名字":"文字" 或 "名字":数字。
Matcher mt = Pattern.compile("\"(\\w+)\"\\s*:\\s*(\"([^\"]*)\"|([-\\d.]+))").matcher(body);
while (mt.find()) m.put(mt.group(1), mt.group(3) != null ? mt.group(3) : mt.group(4));
普通申请读得出来。于是发送了三件有点刁钻、但在现场很常见的申请。
# a 普通的申请
发送的正文: {"date":"2026-10-26","item":"交通費","amount":1280}
读到的值: {date=2026-10-26, item=交通費, amount=1280}
→ {"id":"EXP-2026-0012","item":"交通費","status":"submitted"} -> 201 (0.057934 s)
# b 项目里含引号(会議費 "A社")
发送的正文: {"date":"2026-10-26","item":"会議費 \"A社\"","amount":3300}
读到的值: {date=2026-10-26, item=会議費 \, amount=3300}
→ {"id":"EXP-2026-0013","item":"会議費 \","status":"submitted"} -> 201 (0.004617 s)
# c 用 Python 的 json.dumps 发送(默认把日文转成 \u 转义)
发送的正文: {"date": "2026-10-26", "item": "\u4ea4\u901a\u8cbb", "amount": 1280}
读到的值: {date=2026-10-26, item=\u4ea4\u901a\u8cbb, amount=1280}
→ 201 {"id":"EXP-2026-0014","item":"\u4ea4\u901a\u8cbb","status":"submitted"}
# d 带收据明细(嵌套的 amount)
发送的正文: {"date":"2026-10-26","amount":1280,"item":"交通費","receipts":[{"amount":500},{"amount":780}]}
读到的值: {date=2026-10-26, amount=780, item=交通費}
→ {"id":"EXP-2026-0015","item":"交通費","status":"submitted"} -> 201 (0.003378 s)
- b 引号 ── 在 JSON 里,文字中的 " 要写成 \"。正则表达式在第一个 " 处停下,项目变成 会議費 \。而且把它原样塞进回复,连回复的 JSON 都坏了
- c \u 转义 ── Python 的 json.dumps 默认把日文写成 交…。这是正确的 JSON,却被原样存成了“项目”
- d 嵌套 ── 捡到了明细里的 amount,金额 1,280 日元变成了 780 日元。没有报错,还以 201 受理了
最可怕的是 d。抛出异常停下来还能发现,它却默默地收下了错误的金额。JSON 的规范(RFC 8259)篇幅不长,但把字符转义、嵌套、数字的写法都认真读下来,就知道手写不划算。写的一方也一样,把值直接塞进字符串,一个引号就能弄坏 JSON,糟糕时还能混入别的字段。JSON 的读写,在第 5 讲交给 Jackson。
JDK 的 HttpServer
其实 JDK 里自带一个小小的 HTTP 服务器:com.sun.net.httpserver 包(jdk.httpserver 模块)。写同样的申请 API,是 31 行。
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/api/expenses", ex -> {
String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
if (!ex.getRequestMethod().equals("POST")) { reply(ex, 405, "{\"error\":\"method not allowed\"}"); return; }
var req = RawServer.naiveJson(body); // JSON 还是手工读
String id = "EXP-2026-%04d".formatted(seq.getAndIncrement());
ex.getResponseHeaders().add("Location", "/api/expenses/" + id);
reply(ex, 201, "{\"id\":\"%s\",\"item\":\"%s\",\"status\":\"submitted\"}".formatted(id, req.get("item")));
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // 每个请求一个虚拟线程
server.start();
…
static void reply(HttpExchange ex, int code, String json) throws IOException {
byte[] b = json.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(code, b.length); // 长度按字节算
try (OutputStream os = ex.getResponseBody()) { os.write(b); }
读第 1 行和首部、Content-Length、分配线程,都成了 HttpServer 的工作,长度只要把字节数交给 sendResponseHeaders。发送坏掉的请求,和自制服务器比较。
# 用 nc 发送坏掉的请求(只有一行 HELLO)
pc$ printf 'HELLO\r\n\r\n' | nc -w 2 expenses.example 8080
# 自制 RawServer 的回复
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 21
Connection: close
{"error":"not found"}
# JDK HttpServer 的回复
HTTP/1.1 400 Bad Request
Content-Length: 40
Content-Type: text/html
Connection: close
<h1>400 Bad Request</h1>Bad request line
# 对 JDK HttpServer 发送普通 POST 和 GET
POST → {"id":"EXP-2026-0012","item":"交通費","status":"submitted"} -> 201 (0.063106 s)
GET → {"error":"method not allowed"} -> 405
自制服务器把 HELLO 这行毫无意义的文字当成“方法为 HELLO 的请求”接受了,还一本正经地返回 404。HttpServer 则说“第 1 行不对”,返回 400。检查请求的格式,也是服务器的重要工作。另一方面,对 GET 返回 405 是自己写的那一行,JSON 依然是手工读。作为工具,它的大小正适合在测试里搭一个假的 API,或者做公司内部的小工具。
徒手做过才明白的事
徒手做过的工作,由谁来完成
在第 1 讲的 Spring Boot 中,Tomcat 接受连接、分配工人,读第 1 行、首部和正文,拒绝坏掉的请求,并在一定时间后切断沉默的客人。Spring MVC 按 URL 和方法选择窗口,Jackson 读写 JSON。这次我们自己做了其中几项,也做错了几项。
徒手写是为了理解原理。工作中的代码,HTTP 交给久经考验的服务器(Tomcat 等),JSON 交给久经考验的库(Jackson 等)。在此基础上,长度按字节、每人一个工人、等待要有上限、不要手工读 JSON这四点,无论用什么工具,都是看设置和日志时的路标。
新井Tomcat 真了不起啊……
古井了不起。所以要亲手做一次,看看了不起的人在做什么。古时候的徒弟,先模仿一遍师傅的活儿,才被允许用师傅的工具。
在自己的电脑上验证
| 想试的事 | 命令 |
|---|---|
| 直接运行源文件(无需编译) | java RawServer.java 8080 |
| 查看发送和收到的行 | curl -v -d '{"item":"交通費"}' localhost:8080/api/expenses |
| 按字符数写长度的 bug | java -Dbug.cl=true RawServer.java 8080 |
| 手敲 HTTP(一行一行输入,最后输入空行) | nc localhost 8080 |
| 制造沉默的客人 | sleep 10 | nc localhost 8080 |
| 用虚拟线程运行 | java -Dthreads=virtual RawServer.java 8080 |
| JDK 自带的小文件服务器(Java 18 起) | jwebserver -p 8000 |
Windows 上请用 WSL 的终端代替 nc,或者使用 PowerShell 的 curl.exe。停止服务器按 Ctrl+C。
结 — 一页总结与下一讲
- HTTP 是文字行:第 1 行、到空行为止的首部、按 Content-Length 读的正文。用 ServerSocket 就能读。
- 正文没有结束标记。长度按字节写。按字符数写,只有项目是日文时回复才会断掉。
- 一个一个处理时,一个沉默的客人就能卡住所有人。50 件要 5.26 秒。
- 每人一个线程只要 0.2 秒。虚拟线程能便宜地容纳在等的客人,但等待时间的上限依然需要。
- 手工读 JSON,会被引号、\u、嵌套弄错。金额默默变成了 780 日元。
- JDK 的 HttpServer 31 行就能返回 400。在 Spring Boot 中,这些由 Tomcat、Spring MVC 和 Jackson 分担。
新井下一讲回到 Spring Boot 吗?
古井还早。下一讲连数据库也徒手做。用 JDBC 拼 SQL,在自己的数据库上确认为什么“不能用拼接字符串的方式写 SQL”。
参考
- RFC 9112 — HTTP/1.1(Message Format、Content-Length)
- RFC 9110 — HTTP Semantics(400、404、405)
- RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format
- Java SE 25 API — ServerSocket
- Java SE 25 API — Executors(newVirtualThreadPerTaskExecutor)
- Java SE 25 API — com.sun.net.httpserver.HttpServer
- JEP 444 — Virtual Threads
- JEP 408 — Simple Web Server
- JEP 330 — Launch Single-File Source-Code Programs
- Spring Boot — Task Execution and Scheduling(spring.threads.virtual.enabled)
记录是笔者于 2026 年 10 月 27 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 OpenJDK 25.0.4、curl 8.5.0、Wireshark 4.2.2 取得的。PC 和申请服务器是在一台机器里建立的不同网络空间(network namespace),地址与网络篇相同。时间是当时一次运行的值,每次运行都会略有不同。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。
古法后端入门 第3讲 — 徒手接收 HTTP / 2026.10