Old-School Backend / 2026 ・ No.03

古法后端入门

第3讲 徒手接收 HTTP ── ServerSocket、线程、JDK 的 HttpServer

发布

终端画面。上方是申请服务器运行 java RawServer 8080 的日志:连接、第 1 行 POST /api/expenses HTTP/1.1、首部 5 行、正文 54 字节、读到的值、返回 201 Created(Content-Length: 62,正文 62 字节)。下方是在 PC 上执行 curl -i,返回 HTTP/1.1 201 Created、Content-Length: 62、Location: /api/expenses/EXP-2026-0012
只用 JDK 写的 86 行申请服务器(上),从 PC 用 curl 发送一件申请(下)。第 1 行、首部、正文,HTTP 一行一行由自己读,然后返回 201(笔者于 2026 年 10 月 27 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

第 1 讲用 Spring Boot 写了申请 API,用一个小小的“窗口”接收申请。可是在它下面接收、读取、回复 HTTP 的,并不是我们自己的代码。
这次不用 Spring,只用 JDK 接收 HTTP:用 ServerSocket 一行一行地读,数 Content-Length,分配线程,手工读 JSON。徒手做一遍,实测会在哪里划破手。

一

徒手接收

新井Spring Boot 写一行 @PostMapping 就能接收,为什么还要特意徒手?

古井不知道那一行下面发生了什么,等生产环境有人说“偶尔有一件在中途断掉”的时候,你就不知道该看哪里。

新井真会发生那种事吗?

古井今天就亲手让它发生。

全 15 讲路线图(当前:第 3 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 一行一行地读 ── 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 请求

POST /api/expenses HTTP/1.1 ⏎ 29 B Host: expenses.example:8080 ⏎ 29 B Content-Type: application/json ⏎ 32 B … 另有 2 行 Content-Length: 54 ⏎ 20 B 空行(只有 CRLF)=首部结束 2 B {"date":"2026-10-26",…} 54 B 读第 1 行 做什么・对哪里・哪个版本 读首部直到空行 重复 readLine 在这里分隔 之后是正文 按 Content-Length 读取 readNBytes(54)。没有结束标记 行可以按文字读,正文却必须先知道“有几个字节”才能读 第 1 行到首部 149 字节+正文 54 字节=线上的 203 字节(实测)

行只要读到换行(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 字节。按字符写长度,正文就会被截断

回复正文(UTF-8 字节) {"id":"EXP-2026-0012","item":" 30 B 交通費 9 B ","status":"submi 17 B tted"} 6 B Content-Length: 56 就到这里为止 溢出 6 B json.length() → 56(字符数) json.getBytes(UTF_8).length → 62(字节数) 长度按字节算。只要有 1 个汉字或日文字符,就和字符数对不上 实测:curl 说“Excess found … excess = 6”,在 56 字节处截断 只用英文和数字测试,发现不了这个 bug(第 2 讲:交通費是 9 字节)

服务器把 62 字节全部发出去了。可是 curl 说“听说 Content-Length 是 56,却多了 6 字节”,读到 56 字节就停下,正文在 "submi 处断掉。正如第 2 讲所见,“交通費”是 3 个字符、9 个字节。相差的 6 字节就这样溢出了。

Wireshark 画面。打开第 8 个包 HTTP/1.1 201 Created,可以看到 Content-Length: 56 和 File Data: 56 bytes。右下的字节显示中,选中的正文范围在 status 的 submi 处结束。下方标签是 Frame (128 bytes) 和 Reassembled TCP (190 bytes)
用 Wireshark 打开按字符数写 Content-Length 的回复。线上明明载着全部 62 字节正文,作为 HTTP 被读取的却只有 56 字节,在 submi 处截断(笔者于 2026 年 10 月 27 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

用 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 申请。

终端画面。上方申请服务器的日志只有开始监听和 连接 /192.168.1.10:50942 两行。下方 PC 上先用 nc 制造一个只连接不发送的客人,接着用 curl -m 3 申请,结果是 curl: (28) Operation timed out after 3002 milliseconds with 0 bytes received
一个一个按顺序接待的服务器,只要有一个只连接、不说话的客人(nc),下一件申请等了 3 秒也收不到 1 个字节。服务器日志里只有沉默客人的“连接”(笔者于 2026 年 10 月 27 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

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 件:一个一个 与 每人一个(实测)

同时发送 50 件,直到全部完成(每件 100 ms 的工作) single 5.261 s 工人 1 个 platform 0.196 s 工人 50 个 virtual 0.225 s 工人 50 个 一个一个处理时,第 50 个人要等前面 49 个人。5 秒正是 100 ms × 50 件 single:accept 之后,在这个客人结束前不接下一个客人 platform:每来一个人一个 OS 线程。增加到几百、几千就很吃内存 virtual:Java 21 起的轻量线程。等待时会让出 OS 线程 工人数是按日志里的线程名数出来的(只有 main/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,或者做公司内部的小工具。

八

徒手做过才明白的事

徒手做过的工作,由谁来完成

工作 自制 RawServer JDK HttpServer Spring Boot 接受连接,分配给工人 ○ 自己做 ○ Tomcat 读第 1 行和首部 ○ 自己做 ○ Tomcat 按 Content-Length 读正文 ○ 自己做 ○ Tomcat 按字节写长度 △ 写错了 ○ Tomcat 对坏请求返回 400 × 返回了 404 ○ 400 Tomcat 按 URL 和方法分派 ○ 自己做 △ 405 自己做 Spring MVC 读・写 JSON × 正则表达式 × 手工 Jackson 切断沉默的客人 × 一直等 (本次未测) Tomcat 第 1 讲那 112 行 Java 下面,运行着这么多工作 ○=做到了,△=一部分要自己做,×=没做到(都来自本次实测和所写的代码) Tomcat 的等待时间由 connectionTimeout 决定。JSON 在第 5 讲交给 Jackson

在第 1 讲的 Spring Boot 中,Tomcat 接受连接、分配工人,读第 1 行、首部和正文,拒绝坏掉的请求,并在一定时间后切断沉默的客人。Spring MVC 按 URL 和方法选择窗口,Jackson 读写 JSON。这次我们自己做了其中几项,也做错了几项。

POINT

徒手写是为了理解原理。工作中的代码,HTTP 交给久经考验的服务器(Tomcat 等),JSON 交给久经考验的库(Jackson 等)。在此基础上,长度按字节、每人一个工人、等待要有上限、不要手工读 JSON这四点,无论用什么工具,都是看设置和日志时的路标。

新井Tomcat 真了不起啊……

古井了不起。所以要亲手做一次,看看了不起的人在做什么。古时候的徒弟,先模仿一遍师傅的活儿,才被允许用师傅的工具。

九

在自己的电脑上验证

想试的事命令
直接运行源文件(无需编译)java RawServer.java 8080
查看发送和收到的行curl -v -d '{"item":"交通費"}' localhost:8080/api/expenses
按字符数写长度的 bugjava -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。

结 — 一页总结与下一讲

  1. HTTP 是文字行:第 1 行、到空行为止的首部、按 Content-Length 读的正文。用 ServerSocket 就能读。
  2. 正文没有结束标记。长度按字节写。按字符数写,只有项目是日文时回复才会断掉。
  3. 一个一个处理时,一个沉默的客人就能卡住所有人。50 件要 5.26 秒。
  4. 每人一个线程只要 0.2 秒。虚拟线程能便宜地容纳在等的客人,但等待时间的上限依然需要。
  5. 手工读 JSON,会被引号、\u、嵌套弄错。金额默默变成了 780 日元。
  6. JDK 的 HttpServer 31 行就能返回 400。在 Spring Boot 中,这些由 Tomcat、Spring MVC 和 Jackson 分担。

新井下一讲回到 Spring Boot 吗?

古井还早。下一讲连数据库也徒手做。用 JDBC 拼 SQL,在自己的数据库上确认为什么“不能用拼接字符串的方式写 SQL”。

参考

记录是笔者于 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

← 返回文章列表