本节目标:把
ByteBuffer里的字节变成HttpRequest,让不完整输入、超限输入和格式错误各自落到明确的 4xx。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer绝对读写、record、Map.copyOf。
承接的问题
7.3 的连接状态机只回答「这条连接空闲多久、该不该关」,没有回答「这一串字节里,一条请求到哪里结束」。linkd 的 HTTP 端口 9090 收到请求时,第一次 read 可能只有 GET /healthz HTTP/1.1\r\nHo,下一次才补齐剩下的头部。把每次 read 当成一条请求,会在头部中间断开;把 Content-Length 当成可有可无的提示,会把下一条请求的字节算进上一条的 body。
这两个错误都不会立刻报错。它们表现为「偶尔 400」「并发上来后响应串了」「压测时解析器卡在 NEED_MORE」。8.1 要给出一个可以反复调用的解析器:输入不完整时保留现场,输入完整时把位置停在消息末尾,让调用方决定是继续解析同一条连接上的下一条请求,还是等下一次读取。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。本节输出全部来自该环境下的 JDK 单进程测试,不涉及抓包。
核心机制一:一条请求由四个部分组成
HTTP/1.1 的请求在字节层是四段:请求行、零到多个头部字段、一个空行、可选的消息体。请求行用单个空格分成三段——方法、请求目标、版本;头部每行是 name: value,以 CRLF 结束;空行(连续两个 CRLF)表示头部结束;消息体长度由 Content-Length 或 Transfer-Encoding 决定。
两个 record 是整章的数据契约。HttpRequest 保存方法、原始请求目标、版本、头部和 body:
package com.plume.linkd.http;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public record HttpRequest(String method, String target, String version,
Map<String, String> headers, byte[] body) {
public HttpRequest {
headers = Map.copyOf(headers);
body = body == null ? new byte[0] : body.clone();
}
public String header(String name) {
for (Map.Entry<String, String> e : headers.entrySet())
if (e.getKey().equalsIgnoreCase(name)) return e.getValue();
return null;
}
public String path() {
int q = target.indexOf('?');
return q < 0 ? target : target.substring(0, q);
}
public String bodyText() { return new String(body, StandardCharsets.UTF_8); }
public boolean isHead() { return method.equalsIgnoreCase("HEAD"); }
}
Map.copyOf 在构造时复制一份不可变映射,body.clone() 防止调用方改到解析器内部数组。头部名大小写不敏感,但存储时保留原始写法,header("host") 和 header("Host") 都能取到。path() 只去掉查询串,不做百分号解码;解码属于路由层,8.3 会看到为什么不能在这里做。
HttpResponse 的形状更简单,只有状态码、头部和 body:
package com.plume.linkd.http;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public record HttpResponse(int status, Map<String, String> headers, byte[] body) {
public HttpResponse {
headers = Map.copyOf(headers);
body = body == null ? new byte[0] : body.clone();
}
public String header(String name) {
for (Map.Entry<String, String> e : headers.entrySet())
if (e.getKey().equalsIgnoreCase(name)) return e.getValue();
return null;
}
public String bodyText() { return new String(body, StandardCharsets.UTF_8); }
public static HttpResponse text(int status, String text) {
return new HttpResponse(status, Map.of("Content-Type", "text/plain; charset=utf-8"),
text.getBytes(StandardCharsets.UTF_8));
}
public static HttpResponse json(int status, String json) {
return new HttpResponse(status, Map.of("Content-Type", "application/json; charset=utf-8"),
json.getBytes(StandardCharsets.UTF_8));
}
}
record 的规范构造器负责防御性复制,访问器仍然直接返回内部数组。这个取舍是有意的:解析器和响应编码器都在同一进程内,复制一次足够;如果要把 body 交给业务线程长期持有,调用方应自己再复制一份。Map.copyOf 不保留插入顺序,所以响应编码不能依赖头部遍历顺序,8.2 会按固定顺序写状态行和关键头部。
核心机制二:请求行与头部的增量解析
HttpParser.parse(ByteBuffer) 的调用契约只有三条:
- 调用前缓冲区处于读模式(
flip()之后),position指向消息起点。 - 返回
NEED_MORE时,解析器已经调用compact(),未消费的字节被移到缓冲区开头,调用方可以继续channel.read(in)。 - 返回
DONE时,position停在消息体之后;如果后面还有字节,就是同一条连接上的下一条请求,可以直接再调用parse。
解析器不做相对读,而是用绝对下标扫描,只有确认消息完整后才推进 position。这样同一个方法既能处理半包,也能处理粘包,不需要额外的状态字段:
package com.plume.linkd.http;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
public final class HttpParser {
public enum State { NEED_MORE, DONE, ERROR }
public record ParseResult(State state, HttpRequest request, int errorStatus, String message) {
static ParseResult needMore() { return new ParseResult(State.NEED_MORE, null, 0, null); }
static ParseResult done(HttpRequest r) { return new ParseResult(State.DONE, r, 0, null); }
static ParseResult error(int s, String m) { return new ParseResult(State.ERROR, null, s, m); }
}
public static final int MAX_REQUEST_LINE = 8 * 1024;
public static final int MAX_HEADER_BYTES = 16 * 1024;
public static final int MAX_HEADERS = 100;
private final int maxBodyBytes;
public HttpParser() { this(64 * 1024); }
public HttpParser(int maxBodyBytes) { this.maxBodyBytes = maxBodyBytes; }
public ParseResult parse(ByteBuffer in) {
int start = in.position();
int limit = in.limit();
int lineEnd = findCrlf(in, start, limit);
if (lineEnd < 0) {
if (limit - start > MAX_REQUEST_LINE) return ParseResult.error(414, "request line too long");
in.compact();
return ParseResult.needMore();
}
if (lineEnd - start > MAX_REQUEST_LINE) return ParseResult.error(414, "request line too long");
String[] parts = ascii(in, start, lineEnd).split(" ", -1);
if (parts.length != 3) return ParseResult.error(400, "malformed request line");
String method = parts[0], target = parts[1], version = parts[2];
if (!isToken(method)) return ParseResult.error(400, "invalid method");
if (!target.startsWith("/") && !target.equals("*")) return ParseResult.error(400, "invalid target");
if (!version.equals("HTTP/1.1") && !version.equals("HTTP/1.0"))
return ParseResult.error(505, "unsupported HTTP version");
int pos = lineEnd + 2, headerBytes = 0, count = 0;
Map<String, String> headers = new LinkedHashMap<>();
while (true) {
int end = findCrlf(in, pos, limit);
if (end < 0) {
if (headerBytes + limit - pos > MAX_HEADER_BYTES)
return ParseResult.error(431, "header section too large");
in.compact();
return ParseResult.needMore();
}
headerBytes += end - pos + 2;
if (headerBytes > MAX_HEADER_BYTES) return ParseResult.error(431, "header section too large");
if (end == pos) { pos = end + 2; break; }
if (++count > MAX_HEADERS) return ParseResult.error(431, "too many headers");
if (in.get(pos) == ' ' || in.get(pos) == '\t')
return ParseResult.error(400, "obsolete line folding");
int colon = -1;
for (int i = pos; i < end; i++) if (in.get(i) == ':') { colon = i; break; }
if (colon <= pos) return ParseResult.error(400, "malformed header line");
String name = ascii(in, pos, colon);
if (!isToken(name)) return ParseResult.error(400, "invalid header name");
int vs = colon + 1, ve = end;
while (vs < ve && (in.get(vs) == ' ' || in.get(vs) == '\t')) vs++;
while (ve > vs && (in.get(ve - 1) == ' ' || in.get(ve - 1) == '\t')) ve--;
String value = ascii(in, vs, ve);
if (!isFieldValue(value)) return ParseResult.error(400, "invalid header value");
String key = findKey(headers, name);
if (key == null) headers.put(name, value);
else if (key.equalsIgnoreCase("Content-Length") && !headers.get(key).equals(value))
return ParseResult.error(400, "conflicting Content-Length");
else headers.put(key, headers.get(key) + ", " + value);
pos = end + 2;
}
if (version.equals("HTTP/1.1") && findKey(headers, "Host") == null)
return ParseResult.error(400, "missing Host header");
if (findKey(headers, "Transfer-Encoding") != null)
return ParseResult.error(501, "Transfer-Encoding not enabled");
long bodyLength = 0;
String contentLength = header(headers, "Content-Length");
if (contentLength != null) {
long declared = -1;
for (String piece : contentLength.split(",", -1)) {
String value = piece.trim();
if (value.isEmpty() || value.length() > 18 || !value.chars().allMatch(Character::isDigit))
return ParseResult.error(400, "invalid Content-Length");
long parsed = Long.parseLong(value);
if (declared >= 0 && declared != parsed)
return ParseResult.error(400, "conflicting Content-Length");
declared = parsed;
}
bodyLength = Math.max(declared, 0);
}
if (bodyLength > maxBodyBytes) return ParseResult.error(413, "body too large");
if (pos - start + bodyLength > in.capacity())
return ParseResult.error(413, "body exceeds parser buffer");
if (limit - pos < bodyLength) { in.compact(); return ParseResult.needMore(); }
byte[] body = new byte[(int) bodyLength];
in.get(pos, body);
in.position(pos + (int) bodyLength);
return ParseResult.done(new HttpRequest(method, target, version, headers, body));
}
private static boolean isToken(String s) {
if (s.isEmpty()) return false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean tchar = c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|| c >= '0' && c <= '9' || "!#$%&'*+-.^_`|~".indexOf(c) >= 0;
if (!tchar) return false;
}
return true;
}
private static boolean isFieldValue(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 && c != '\t' || c == 0x7f) return false;
}
return true;
}
private static String findKey(Map<String, String> headers, String name) {
for (String key : headers.keySet()) if (key.equalsIgnoreCase(name)) return key;
return null;
}
private static String header(Map<String, String> headers, String name) {
String key = findKey(headers, name);
return key == null ? null : headers.get(key);
}
private static int findCrlf(ByteBuffer in, int from, int limit) {
for (int i = from; i + 1 < 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);
}
}
几个约束值得单独说明。请求行必须正好三个 token,用单个空格分隔;连续空格会产生空 token,直接 400。版本只接受 HTTP/1.1 和 HTTP/1.0,其余返回 505。头部字段名按 RFC 9110 的 tchar 校验,冒号前不能有空格;name : value 这种写法在部分代理上会被解释成另一个字段,是请求走私的常见入口,这里直接拒绝。
重复头部合并成逗号分隔的值,唯独 Content-Length 特殊:两个值不相等就 400。RFC 允许把完全相同的重复 Content-Length 合并,所以 5, 5 可以通过,5, 6 必须失败。HTTP/1.1 还要求 Host,缺失时 400;HTTP/1.0 不要求。
NEED_MORE 分支里的 compact() 不是「反转」,它把 position 到 limit 之间未消费的字节移到 0,把 position 设成剩余字节数,为下一次 read 腾出空间。调用方下一次必须先 flip() 再调用 parse。如果缓冲区已经写满、解析器仍返回 NEED_MORE,说明消息超过缓冲区容量;8.2 的处理器会把这种情况当成请求过大并关闭连接。
这套实现每次 NEED_MORE 都从头重扫,最坏情况下是 O(n²)。对 8 KiB 请求行、16 KiB 头部、64 KiB body 的上限来说可以接受,代码也更容易讲清楚。生产级解析器会保存「已解析到哪一行」的状态,避免重复扫描;这是性能优化,不改变状态机的语义。
核心机制三:Content-Length、截断与状态码
头部结束后,解析器只看 Content-Length。没有这个头就认为 body 长度为 0;有的话逐段校验十进制数字,合并重复值,超过 maxBodyBytes 直接 413。limit - pos < bodyLength 时返回 NEED_MORE,把已经到达的部分留在缓冲区里;只有字节数够了才复制 body 并推进 position。
这一步把「截断」和「格式错误」分开了。截断是 NEED_MORE,不是错误:客户端可能只是还没发完。真正的错误是连接在消息中间关闭,这时 read 返回 -1,解析器仍然是 NEED_MORE,由 8.2 的处理器补一个 400 并关闭。把 EOF 当成 400 而不是 500,是因为问题在客户端没有发完请求,不在服务端。
状态码的分工如下:
| 情况 | 状态码 | 触发点 |
|---|---|---|
| 请求行/头部语法错误、缺 Host、Content-Length 冲突 | 400 | 解析器直接返回 ERROR |
| 请求行超过 8 KiB | 414 | 请求行扫描 |
| 头部总字节超过 16 KiB 或字段超过 100 个 | 431 | 头部循环 |
| body 超过 64 KiB 或超过解析缓冲区 | 413 | Content-Length 校验 |
| 版本不是 HTTP/1.0/1.1 | 505 | 请求行校验 |
Transfer-Encoding 在本节返回 501,这是分阶段实现:8.1 只做 Content-Length,chunked 的解码、编码和 keep-alive 决策放到 8.2。返回 501 而不是 400,是因为请求本身合法,只是服务端这一版还没实现这种传输编码;8.2 会把这一支替换成真正的 chunk 解码。
在 linkd 里落地:半包、粘包与三个真实结果
linkd-http 模块里,HttpParser 是唯一把字节变成 HttpRequest 的地方。下面这段驱动把同一个请求拆成两次写入,中间调用 compact(),模拟 7.2 讲过的半包:
HttpParser parser = new HttpParser();
ByteBuffer in = ByteBuffer.allocate(4096);
in.put("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhe"
.getBytes(StandardCharsets.ISO_8859_1)).flip();
HttpParser.ParseResult first = parser.parse(in); // NEED_MORE,position 被 compact 到剩余字节数
in.compact();
in.put("llo".getBytes(StandardCharsets.ISO_8859_1)).flip();
HttpParser.ParseResult second = parser.parse(in); // DONE,body=hello
本机输出(Temurin 25.0.5+8,单进程;pos 是调用后缓冲区的 position):
split-first: state=NEED_MORE pos=53
split-second: state=DONE method=POST path=/echo host=x body=5 bodyText=hello pos=56
pipeline-first: state=DONE method=GET path=/a host=x body=0 pos=28
pipeline-second: state=DONE method=GET path=/b host=x body=0 pos=56
header-too-large: state=ERROR status=431 message=header section too large pos=0
body-too-large: state=ERROR status=413 message=body too large pos=0
malformed-line: state=ERROR status=400 message=malformed request line pos=0
conflicting-length: state=ERROR status=400 message=conflicting Content-Length pos=0
前两行是半包:第一次调用只拿到 he,解析器返回 NEED_MORE 并把 53 个未消费字节留在缓冲区;补上 llo 之后返回 DONE,body 正好 5 字节。第 3、4 行是粘包:一个缓冲区里放了两条 GET,第一次 parse 停在第一条的末尾(pos=28),第二次从 28 继续,读到第二条的末尾(pos=56)。后四行分别验证 431、413、400 和重复 Content-Length 的分支。
这两个能力直接决定 8.2 的写法。KeepAliveHandler 会在 onReadable 里先 flip(),再循环调用 parse;DONE 就交给 HttpRouter.route,NEED_MORE 就 compact() 后返回等下一次读。8.3 的 HttpRouterImpl.route(HttpRequest) 拿到的就是这里构造出来的 record,不再碰字节。
常见错误与边界
错误一:只认 \n 不认 \r\n。 printf 'GET / HTTP/1.1\n\n' | nc 127.0.0.1 9090 不会得到响应,解析器会一直 NEED_MORE,直到请求行或头部超过上限。HTTP/1.1 的语法要求 CRLF;容忍裸 LF 会让请求走私和缓存投毒有更多解释空间。要调试就显式写 \r\n,例如 printf 'GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n' | nc。
错误二:把 Content-Length 当提示。 声明 5 字节却发了 10 字节,多出来的 5 字节会被当成下一条请求的请求行;解析器通常返回 400,连接被关闭。声明 10 字节只发 5 字节,解析器停在 NEED_MORE,客户端如果半关闭,处理器读到 EOF 才补 400。body 边界必须严格按声明长度切,不能按「这次 read 到了多少」切。
错误三:头部上限只算值不算行。 只统计 value 长度会漏掉字段名、冒号和 CRLF;攻击者可以用很多短头部撑爆内存。这里统计的是整个头部区的字节数(含 CRLF),并且限制字段个数,两个上限一起用。
错误四:把 NEED_MORE 当成错误直接回 400。 半包是 TCP 的常态,不是客户端错误。只有 read 返回 -1 且仍有未完成消息时,才是「请求被截断」,这时才回 400。把 NEED_MORE 直接映射成 400 会让正常的大请求在慢网络上随机失败。
边界:整个 body 被缓存在内存里。 本节实现用 byte[] 保存 body,所以把上限压到 64 KiB,并且要求消息能放进调用方的缓冲区。需要上传大文件时,应该把 body 流式写给业务处理器,或者落临时文件;这个改造不影响请求行和头部的解析,8.2 的 KeepAliveHandler 也假设 body 已经被完整读出。
边界:不支持 CONNECT 的 authority-form。 本节只接受 origin-form(/path)和 OPTIONS *;CONNECT host:port 的请求目标不以 / 开头,会被 400。正向代理场景需要单独解析 authority-form,linkd 作为源站服务器不需要。
小结
- 请求由请求行、头部、空行、可选 body 四段组成;头部以 CRLF 分隔,空行表示头部结束。
HttpParser.parse用绝对下标扫描,NEED_MORE时compact()保留未消费字节,DONE时把位置停在消息末尾,天然支持半包和粘包。- 请求行 8 KiB 返回 414,头部 16 KiB/100 个字段返回 431,body 64 KiB 返回 413,语法错误返回 400,版本不支持返回 505。
Content-Length必须严格按声明长度切 body;重复值不一致返回 400,不能合并了事。- HTTP/1.1 缺
Host返回 400;头部名必须符合 token 规则,冒号前不能有空格。 - 本节对
Transfer-Encoding返回 501,chunked 的解码与响应编码在 8.2 接上。
下一节把这套解析器放进 KeepAliveHandler:一条连接上连续处理多个请求,决定什么时候保持连接、什么时候关闭,并把 chunked 请求体和分块响应补齐。
阅读导航:上一节:7.3 连接状态机与空闲检测 · 下一节:8.2 keep-alive、chunked 与管线化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。