Old-School Backend / 2026 ・ No.03
いにしえのバックエンド入門
第3回 素手で HTTP を受ける ── ServerSocket、スレッド、JDK の HttpServer
公開
第1回では、Spring Boot に申請 API を書いて、小さな「窓口」で申請を受け取りました。でも、その下で HTTP を受けて、読んで、返していたのは、自分のコードではありません。
今回は Spring を使わず、JDK だけで HTTP を受けます。ServerSocket で1行ずつ読み、Content-Length を数え、スレッドを分け、JSON を手で読む。素手でやって、どこで手を切るかを実測します。
素手で受ける
新井Spring Boot なら @PostMapping を1行書けば受けられるのに、なんでわざわざ素手で?
古井その1行の下で何が起きているか知らないと、本番で「たまに 1 件だけ途中で切れる」と言われたとき、どこを見ればいいか分からない。
新井そんなこと、本当に起きるんですか。
古井今日、自分で起こしてみる。
全15回の路線図(いまここ:第3回)
- 1行ずつ読む ── HTTP は文字の行。空行までがヘッダー、そのあと Content-Length の分だけ本文(実測)
- 長さはバイト ── 文字の数で書くと、返事が 56 バイトで切れた(実測と Wireshark)
- スレッド ── 1人ずつだと 50 件に 5.26 秒、1人に1本なら 0.2 秒。黙っている客1人で全員が止まる(実測)
- 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 行)。ライブラリは1つも使っていません。
ServerSocket で1行ずつ読む
ネットワーク編の第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行目を読み、空行が来るまでヘッダーを1行ずつ読み、最後に Content-Length に書かれた数だけ本文のバイトを読みます。PC から curl で申請を1件送りました。
# PC から curl -v で申請を1件(> は送った行、< は受け取った行)
> 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; // バグ版は「文字の数」を長さにしてしまう 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 バイトは捨てられるだけでした。もし1本の接続を使い回していたら、その 6 バイトは「次の返事の頭」として読まれ、次の返事まで壊れます。品目が英数字だけのテストでは、このバグは1回も出ません。
新井これが「たまに 1 件だけ途中で切れる」……日本語の品目のときだけ。
古井そういうことだ。いにしえの現場では、これを「全角のときだけ化ける」と呼んでいた。
1人ずつだと、黙っている客で止まる
次は、たくさんの客が来たときです。いちばん素直な書き方は、客を1人受け付けたら(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); // 1人ずつ順番に
else pool.submit(() -> handle(s)); // 来た人ごとに別の作業員へ
}
}
-Dthreads=single で動かすと、pool が null なので handle(s) をその場で呼びます。そこへ、接続だけして何も言わない客を1人置いてから、curl で申請しました。
curl は3秒待って、1バイトも受け取れずにあきらめました。サーバーのログには、黙っている客の「接続」が1行あるだけです。サーバーは、その客の1行目を readLine でずっと待っていて、次の客の番が来ません。curl の接続そのものは OS が受け付けて(ネットワーク編で見た3回のあいさつまでは済んで)いるので、curl から見ると「つながったのに返事がない」状態です。
悪気のない客でも、電波の悪いスマホや、途中で止まったプログラムは同じことをします。わざとゆっくり送ってサーバーを止める攻撃もあり、本物のサーバーは、黙っている客を一定時間で切る仕組みを持っています。
スレッドと仮想スレッド
解決の定番は、来た客ごとに別の作業員(スレッド)を付けることです。-Dthreads=platform は OS のスレッドを、-Dthreads=virtual は Java 21 から使える仮想スレッドを、1人に1本ずつ付けます。1件ごとに 100 ミリ秒の「仕事をしているふり」を入れて、PC から 50 件を同時に送りました。
# PC から 50 件を同時に送る。1 件ごとに 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 件を同時に:1人ずつ と 1人に1本(実測)
1人ずつだと 5.261 秒。100 ミリ秒 × 50 件が、そのまま行列になりました。1人に1本なら 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 のスレッドは1本ごとにメモリを確保するので、数千、数万の客には向きません。仮想スレッドは、待っている間は OS のスレッドを手放すので、大勢の「待っている客」を安く抱えられます。ただし、黙っている客がいなくなるわけではありません。接続も仮想スレッドも、客が黙っている限り残り続けます。待つ時間に上限(タイムアウト)を付けるのは、どちらでも必要です。第1回の Tomcat は、OS のスレッドの池(最大 200 本)で客をさばいていて、Spring Boot では設定1つ(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));
ふつうの申請は読めました。そこで、ちょっと意地悪な、でも現場では普通にある申請を3つ送りました。
# 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)は短い文書ですが、文字のエスケープ、入れ子、数の書き方まで、きちんと読むと手書きでは割に合いません。書く側も同じで、値をそのまま文字列に埋めると、引用符1つで 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()); // 1件ごとに仮想スレッド
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 にバイト数を渡すだけです。壊れた頼みごとを送って、自作のサーバーと比べました。
# 壊れた頼みごと(HELLO の1行だけ)を、nc で送る
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 という意味のない1行を「HELLO というメソッドの頼みごと」として受け付け、まじめに 404 を返しました。HttpServer は「1行目がおかしい」と 400 を返します。頼みごとの形を確かめるのも、サーバーの大事な仕事です。一方で、GET に 405 を返すのは自分で書いた行で、JSON はあいかわらず手で読んでいます。道具としては、テストで偽の API を立てたり、社内の小さな道具を作ったりするのにちょうどいい大きさです。
素手でやって分かったこと
素手でやった仕事を、だれがやってくれるか
第1回の Spring Boot では、Tomcat が接続を受けて作業員を割り当て、1行目とヘッダーと本文を読み、壊れた頼みごとを断り、黙っている客を一定時間で切ります。Spring MVC が URL とメソッドで窓口を選び、Jackson が JSON を読み書きします。今回、そのうちのいくつかを自分でやって、いくつかを間違えました。
素手で書くのは、仕組みを知るためです。仕事のコードでは、HTTP は実績のあるサーバー(Tomcat など)に、JSON は実績のあるライブラリ(Jackson など)に任せます。そのうえで、長さはバイト、1人に1本の作業員、待つ時間には上限、JSON は手で読まないの4つは、どの道具を使っていても、設定やログを見るときの目印になります。
新井Tomcat、えらいですね……。
古井えらい。だから、えらい人が何をしているか、一度は自分でやってみる。いにしえの弟子は、親方の仕事を一度まねてから、親方の道具を使わせてもらった。
自分の PC で確かめる
| 試したいこと | コマンド |
|---|---|
| ソースファイルのまま動かす(コンパイル不要) | java RawServer.java 8080 |
| 送った行と受け取った行を見る | curl -v -d '{"item":"交通費"}' localhost:8080/api/expenses |
| 長さを文字の数で書くバグ | java -Dbug.cl=true RawServer.java 8080 |
| HTTP を手で打つ(1行ずつ入力し、最後に空行) | nc localhost 8080 |
| 黙っている客を作る | sleep 10 | nc localhost 8080 |
| 仮想スレッドで動かす | java -Dthreads=virtual RawServer.java 8080 |
| JDK の小さなファイルサーバー(Java 18 から) | jwebserver -p 8000 |
Windows では、nc の代わりに WSL の端末を使うか、PowerShell の curl.exe を使ってください。サーバーを止めるときは Ctrl+C です。
結 — 一枚まとめと、次回
- HTTP は文字の行。1行目、空行までのヘッダー、Content-Length の分の本文。ServerSocket で読める。
- 本文には終わりの印がない。長さはバイトで書く。文字の数で書いたら、日本語の品目のときだけ返事が切れた。
- 1人ずつだと、黙っている客1人で全員が止まる。50 件に 5.26 秒。
- 1人に1本のスレッドで 0.2 秒。仮想スレッドは待っている客を安く抱えられるが、待つ時間の上限はやはり必要。
- JSON を手で読むと、引用符、\u、入れ子で化ける。金額が黙って 780 円になった。
- JDK の HttpServer は 31 行で 400 まで返す。Spring Boot では、これを Tomcat と Spring MVC と Jackson が分担している。
新井次は Spring Boot に戻りますか?
古井まだだ。次回は DB も素手でやる。JDBC で SQL を組み立てて、なぜ「文字をつなげて SQL を作ってはいけない」のかを、自分の DB で確かめる。
参考
- 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)
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、OpenJDK 25.0.4、curl 8.5.0、Wireshark 4.2.2 を使って 2026年10月27日に取ったものです。PC と申請サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、ネットワーク編と同じ住所にしてあります。時間はそのときの1回の値で、動かすたびに少し変わります。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第3回 — 素手で HTTP を受ける / 2026.10