本节目标:让一条 TCP 连接安全地承载多条 HTTP/1.1 请求,能按
Content-Length或 chunked 判断消息边界,并按请求顺序回写响应。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、ByteBuffer、record。
承接的问题
8.1 的 HttpParser 一次只回答「这一条请求完整了吗」,没有回答「这条连接接下来怎么办」。HTTP/1.1 默认复用连接:curl 连续访问两个接口时,往往只建一次 TCP,第二条请求直接写在同一连接上。如果服务端回完第一条就 close(),客户端会看到 Connection reset 或被迫重连;如果服务端不关连接又不说清 body 长度,客户端会一直等到超时。
chunked 是另一个边界问题。请求头里出现 Transfer-Encoding: chunked 时,body 没有 Content-Length,长度由每个 chunk 的大小行决定。8.1 对这一支返回 501;本节把它补成真正的解码器,并让响应也能用 chunked 分块写出。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。本节用 loopback 上的一对真实 SocketChannel 驱动处理器,输出来自该环境。
核心机制一:keep-alive 的决策只看两件事
HTTP/1.1 的连接默认是持久的,除非请求或响应显式写了 Connection: close。HTTP/1.0 相反:默认关闭,只有请求带 Connection: keep-alive 才复用。服务端做决策时看两个输入:
| 输入 | 结果 |
|---|---|
响应头有 Connection: close | 关闭 |
请求头有 Connection: close | 关闭 |
版本 HTTP/1.0,请求无 keep-alive | 关闭 |
版本 HTTP/1.0,请求有 keep-alive | 保持 |
版本 HTTP/1.1,没有 close | 保持 |
保持连接的前提是响应 body 有明确边界:Content-Length 或 Transfer-Encoding: chunked。两者都没有时,客户端只能靠连接关闭判断 body 结束,这时必须关闭连接,不能复用。8.1 的解析器保证了请求侧有边界,本节保证响应侧也有。
核心机制二:chunked 的格式、解码与编码
chunked body 由若干块组成,每块是「十六进制大小行 + CRLF + 数据 + CRLF」,最后以一个大小为 0 的块结束,后面可以跟 trailer 头部,再以一个空行收尾。大小行可以带 ;ext=value 扩展,解码时忽略扩展,但必须校验大小是合法十六进制且不超过上限。
package com.plume.linkd.http;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public final class ChunkedCodec {
public static final int MAX_CHUNK = 64 * 1024;
private static final byte[] CRLF = {'\r', '\n'};
public record DecodeResult(boolean complete, byte[] body, int consumed, String error) {
static DecodeResult needMore() { return new DecodeResult(false, null, 0, null); }
static DecodeResult done(byte[] body, int consumed) { return new DecodeResult(true, body, consumed, null); }
static DecodeResult failed(String message) { return new DecodeResult(false, null, 0, message); }
}
private ChunkedCodec() { }
public static DecodeResult decode(ByteBuffer in) {
int start = in.position(), p = start;
ByteArrayOutputStream body = new ByteArrayOutputStream();
while (true) {
int lineEnd = findCrlf(in, p);
if (lineEnd < 0) return DecodeResult.needMore();
if (lineEnd - p > 1024) return DecodeResult.failed("chunk line too long");
String line = ascii(in, p, lineEnd);
int semi = line.indexOf(';');
String hex = (semi < 0 ? line : line.substring(0, semi)).trim();
int size;
try {
size = Integer.parseUnsignedInt(hex, 16);
} catch (NumberFormatException e) {
return DecodeResult.failed("bad chunk size");
}
if (hex.isEmpty() || !hex.chars().allMatch(c -> Character.digit(c, 16) >= 0)
|| size > MAX_CHUNK) return DecodeResult.failed("bad chunk size");
p = lineEnd + 2;
if (size == 0) {
while (true) {
int trailerStart = p;
int end = findCrlf(in, p);
if (end < 0) return DecodeResult.needMore();
p = end + 2;
if (end == trailerStart) return DecodeResult.done(body.toByteArray(), p - start);
}
}
if (body.size() + size > MAX_CHUNK) return DecodeResult.failed("body too large");
if (in.limit() - p < size + 2) return DecodeResult.needMore();
byte[] chunk = new byte[size];
in.get(p, chunk);
p += size;
if (in.get(p) != '\r' || in.get(p + 1) != '\n')
return DecodeResult.failed("missing CRLF after chunk");
p += 2;
body.writeBytes(chunk);
}
}
public static byte[] encode(byte[] body, int chunkSize) {
if (chunkSize <= 0) throw new IllegalArgumentException("chunkSize <= 0");
ByteArrayOutputStream out = new ByteArrayOutputStream(body.length + 32);
for (int off = 0; off < body.length; off += chunkSize) {
int len = Math.min(chunkSize, body.length - off);
out.writeBytes(Integer.toHexString(len).getBytes(StandardCharsets.US_ASCII));
out.writeBytes(CRLF);
out.write(body, off, len);
out.writeBytes(CRLF);
}
out.writeBytes("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
return out.toByteArray();
}
private static int findCrlf(ByteBuffer in, int from) {
for (int i = from; i + 1 < in.limit(); i++)
if (in.get(i) == '\r' && in.get(i + 1) == '\n') return i;
return -1;
}
private static String ascii(ByteBuffer in, int from, int to) {
byte[] bytes = new byte[to - from];
in.get(from, bytes);
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
decode 从 position 开始绝对扫描,不移动位置;返回 complete=false 时调用方保留字节,下一次补齐后从头再扫。consumed 是整段 chunked body 的字节数,解析器把它加到 body 起点,得到下一条请求的位置。encode 按固定块大小切分,最后写 0\r\n\r\n;块大小用十六进制,不补前导零。
把 8.1 里那个返回 501 的分支替换成下面这段,HttpParser 就能同时处理两种 framing:
String transferEncoding = header(headers, "Transfer-Encoding");
if (transferEncoding != null) {
if (!transferEncoding.equalsIgnoreCase("chunked"))
return ParseResult.error(501, "unsupported transfer coding");
int saved = in.position();
in.position(pos);
ChunkedCodec.DecodeResult r = ChunkedCodec.decode(in);
in.position(saved);
if (r.error() != null) return ParseResult.error(400, r.error());
if (!r.complete()) { in.compact(); return ParseResult.needMore(); }
in.position(pos + r.consumed());
return ParseResult.done(new HttpRequest(method, target, version, headers, r.body()));
}
header(headers, "Transfer-Encoding") 取到的是合并后的值;只有恰好等于 chunked 才进入解码。gzip, chunked 这类组合本节不支持,返回 501,而不是把 gzip 字节当成 chunk 大小行硬解。真实实现要按逗号拆分、逐个判断传输编码,最后一个编码决定 framing。
如果请求同时带 Transfer-Encoding 和 Content-Length,上面这段会走 TE 分支、完全忽略 CL;按 RFC 9112 应把这种请求判为 400 或删除 CL,避免代理与源站对消息边界理解不一致。
核心机制三:管线化的顺序与队头阻塞
管线化(pipelining)是客户端在收到第一个响应之前,就把后续请求写进同一连接。服务端可以逐条处理,但响应必须按请求顺序回写;HTTP/1.1 没有请求 ID,客户端只能靠顺序配对。把两个请求丢给线程池并发处理,谁先完成谁先写,响应就会错配。
顺序的代价是队头阻塞:第一个请求慢,后面已经到达的请求都要等。KeepAliveHandler 因此在 onReadable 的同一个循环里顺序处理,不把单个请求交给别的线程。真要让慢请求不阻塞后续连接,正确做法是限制每条连接的在途请求数,或者升级到 HTTP/2 的多路复用;在 HTTP/1.1 上并发写同一连接只会破坏顺序。
在 linkd 里落地:KeepAliveHandler
处理器把读取、解析、路由、编码、关闭串成一个循环。Connection 和 Handler 来自 com.plume.linkd.core(7.1 到 7.3 已定义),这里不重复实现:
package com.plume.linkd.app;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.Handler;
import com.plume.linkd.http.ChunkedCodec;
import com.plume.linkd.http.HttpParser;
import com.plume.linkd.http.HttpRequest;
import com.plume.linkd.http.HttpResponse;
import com.plume.linkd.http.HttpRouter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
public final class KeepAliveHandler implements Handler {
private static final int INPUT_CAPACITY = 128 * 1024;
private static final DateTimeFormatter HTTP_DATE = DateTimeFormatter
.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
.withZone(ZoneOffset.UTC);
private final HttpParser parser = new HttpParser();
private final HttpRouter router;
private final ByteBuffer in = ByteBuffer.allocate(INPUT_CAPACITY);
private boolean closing;
public KeepAliveHandler(HttpRouter router) { this.router = router; }
@Override
public void onReadable(Connection c) {
if (closing) return;
try {
int n = c.channel().read(in);
if (n < 0) {
if (in.position() > 0) send(c, HttpResponse.text(400, "incomplete request\n"), false, false);
close(c);
return;
}
if (n == 0) return;
in.flip();
while (true) {
HttpParser.ParseResult result = parser.parse(in);
if (result.state() == HttpParser.State.NEED_MORE) {
in.compact();
if (!in.hasRemaining()) {
send(c, HttpResponse.text(400, "request too large\n"), false, false);
close(c);
}
return;
}
if (result.state() == HttpParser.State.ERROR) {
send(c, HttpResponse.text(result.errorStatus(), result.message() + "\n"), false, false);
close(c);
return;
}
HttpRequest request = result.request();
HttpResponse response;
try {
response = router.route(request);
} catch (RuntimeException e) {
response = HttpResponse.text(500, "internal server error\n");
}
boolean keep = keepAlive(request, response);
send(c, response, keep, request.isHead());
if (!keep) { close(c); return; }
if (!in.hasRemaining()) { in.compact(); return; }
}
} catch (IOException e) {
close(c);
}
}
@Override
public void onWritable(Connection c) { }
@Override
public void onClosed(Connection c) { closing = true; }
private void close(Connection c) { closing = true; c.close(); }
private static boolean keepAlive(HttpRequest request, HttpResponse response) {
if (hasToken(response.header("Connection"), "close")) return false;
String connection = request.header("Connection");
if (hasToken(connection, "close")) return false;
if ("HTTP/1.0".equals(request.version())) return hasToken(connection, "keep-alive");
return true;
}
private static boolean hasToken(String value, String token) {
if (value == null) return false;
for (String part : value.split(",")) if (part.trim().equalsIgnoreCase(token)) return true;
return false;
}
private static void send(Connection c, HttpResponse response, boolean keep, boolean head) {
c.enqueue(ByteBuffer.wrap(encode(response, keep, head)));
}
private static byte[] encode(HttpResponse response, boolean keep, boolean head) {
ByteArrayOutputStream out = new ByteArrayOutputStream(response.body().length + 256);
writeAscii(out, "HTTP/1.1 " + response.status() + " " + reason(response.status()) + "\r\n");
Map<String, String> headers = new LinkedHashMap<>(response.headers());
putIfAbsent(headers, "Date", HTTP_DATE.format(ZonedDateTime.now(ZoneOffset.UTC)));
putIfAbsent(headers, "Server", "linkd/0.1");
boolean chunked = hasToken(header(headers, "Transfer-Encoding"), "chunked");
if (chunked) headers.keySet().removeIf(key -> key.equalsIgnoreCase("Content-Length"));
else putIfAbsent(headers, "Content-Length", Integer.toString(response.body().length));
putIfAbsent(headers, "Connection", keep ? "keep-alive" : "close");
for (Map.Entry<String, String> e : headers.entrySet())
writeAscii(out, e.getKey() + ": " + e.getValue() + "\r\n");
writeAscii(out, "\r\n");
if (!head) {
if (chunked) out.writeBytes(ChunkedCodec.encode(response.body(), 4096));
else out.writeBytes(response.body());
}
return out.toByteArray();
}
private static String reason(int status) {
return switch (status) {
case 200 -> "OK"; case 400 -> "Bad Request"; case 403 -> "Forbidden";
case 404 -> "Not Found"; case 405 -> "Method Not Allowed";
case 413 -> "Content Too Large"; case 414 -> "URI Too Long";
case 431 -> "Request Header Fields Too Large"; case 505 -> "HTTP Version Not Supported";
case 500 -> "Internal Server Error"; case 501 -> "Not Implemented";
default -> "Status";
};
}
private static void writeAscii(ByteArrayOutputStream out, String text) {
out.writeBytes(text.getBytes(StandardCharsets.US_ASCII));
}
private static String header(Map<String, String> headers, String name) {
for (Map.Entry<String, String> e : headers.entrySet())
if (e.getKey().equalsIgnoreCase(name)) return e.getValue();
return null;
}
private static void putIfAbsent(Map<String, String> headers, String name, String value) {
if (header(headers, name) == null) headers.put(name, value);
}
}
几个细节决定它能不能长期运行。read 返回 0 表示当前没有新数据,直接返回,不能当 EOF;返回 -1 才是对端关闭。NEED_MORE 时先 compact() 再检查 hasRemaining():缓冲区满了还解析不完,说明消息超过 128 KiB,回 400 并关闭,不能让连接永远占着内存。ERROR 时先把错误响应 enqueue 再 close,依赖 Connection.close() 的语义是「停止读、排空写队列、关闭通道」,否则 400 可能还没发出去连接就没了。
响应编码先写状态行,再写头部,最后写 body。Date、Server、Content-Length、Connection 只在响应没有显式提供时才补,避免路由层和编码层写出两个 Content-Length。如果响应头声明了 Transfer-Encoding: chunked,编码器删掉 Content-Length,改用 ChunkedCodec.encode;HEAD 请求保留长度头但不写 body。
本机验证:顺序、关闭与 chunked
用一对 loopback SocketChannel 驱动 KeepAliveHandler,客户端一次写入两条请求,服务端在同一个 onReadable 里循环解析。本机输出(Temurin 25.0.5+8,单次运行,未做统计):
pipeline: statuses=[200, 200] closed=false bytes=376
http10: statuses=[200] closed=true bytes=157
connection-close: statuses=[200] closed=true bytes=157
chunked-request: statuses=[200] closed=false bytes=164
head: statuses=[200] closed=false bytes=159
chunked-response: statuses=[200] closed=false bytes=174
bad-request: statuses=[400] closed=true bytes=187
pipeline 的两个 200 顺序固定:第一个是 /healthz 的 ok\n,第二个是 /books/7 的 JSON,bytes=376 是两条响应拼接后的总字节数。http10 和 connection-close 都只回一条并置 closed=true,区别在触发条件:前者是 HTTP/1.0 默认关闭,后者是请求显式 Connection: close。chunked-request 把 hello 分成一个 5 字节块发送,处理器解码后交给 /echo,响应仍是 Content-Length: 5 且连接保持。
分块响应的原始字节如下(\r\n 为转义显示):
HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n
Date: Wed, 28 Oct 2026 09:43:02 GMT\r\nServer: linkd/0.1\r\n
Connection: keep-alive\r\n\r\nb\r\nhello world\r\n0\r\n\r\n
b 是 11 的十六进制,后面跟 hello world,再跟结束块 0\r\n\r\n。响应没有 Content-Length,客户端靠 chunked 语法判断结束;连接因此可以继续复用。bad-request 送的是缺少版本的请求行,处理器回 400 并关闭,closed=true。
常见错误与边界
错误一:HTTP/1.1 回完就关。 这是把「短连接」当默认。客户端第二次请求会复用已经收到 FIN 的连接,表现为 Connection reset 或 Broken pipe。除非响应没有长度边界、请求写了 close,或者服务端正在停机,否则不要主动关。
错误二:chunked 响应同时写 Content-Length。 两个 framing 冲突,不同客户端和代理会选择不同的解释,可能把 chunk 大小行当成 body 的一部分。编码器必须在 chunked 分支删除 Content-Length,而不是两个都发。
错误三:把管线化请求并发处理。 HTTP/1.1 没有请求 ID,响应顺序就是配对依据。两个请求交给线程池、谁先完成谁先写,客户端会把第二个响应配给第一个请求。要并发就升级协议,或者限制每条连接同时只有一个在途请求。
边界:本节的 chunked 解码器把整个 body 收进内存。 上限 64 KiB,适合表单和 JSON;大文件上传应该边解码边写临时文件,不能等 0\r\n\r\n 再分配。decode 每次 NEED_MORE 都从头重扫,和 8.1 的解析器一样是教学实现,生产版应保存块状态。
小结
- HTTP/1.1 默认 keep-alive,
Connection: close或 HTTP/1.0 无keep-alive才关闭;响应必须有Content-Length或 chunked 边界。 - chunked 的块是「十六进制大小 + CRLF + 数据 + CRLF」,以
0\r\n\r\n结束,扩展和 trailer 要能跳过。 ChunkedCodec.decode不移动缓冲区位置,返回consumed让解析器推进;encode按固定块大小切分。KeepAliveHandler在同一个onReadable循环里顺序处理,响应入队顺序就是请求顺序。NEED_MORE要compact()后等下一次读;缓冲区满仍不完整,回 400 并关闭,避免内存被一条连接占死。- 关闭前必须让写队列排空,否则错误响应可能丢在
close()里。
下一节把 HttpRequest 交给路由:注册 /healthz 和 /books/{id},区分 404、405、500,并在静态文件服务里处理路径穿越。
阅读导航:上一节:8.1 请求行、头部与消息体 · 下一节:8.3 路由、静态文件与错误响应 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。