《Java 网络编程基础》8.1 请求行、头部与消息体

HTTP/1.1 的第一层是字节到消息的边界。本节实现 HttpRequest、HttpResponse 与 HttpParser,解析请求行和头部,给请求行、头部总字节与消息体设上限,用 400、413、431、414 区分格式错误与超限,并用分片读取、管线化输入验证解析器的增量行为。

本节目标:把 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) 的调用契约只有三条:

  1. 调用前缓冲区处于读模式(flip() 之后),position 指向消息起点。
  2. 返回 NEED_MORE 时,解析器已经调用 compact(),未消费的字节被移到缓冲区开头,调用方可以继续 channel.read(in)。
  3. 返回 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 KiB414请求行扫描
头部总字节超过 16 KiB 或字段超过 100 个431头部循环
body 超过 64 KiB 或超过解析缓冲区413Content-Length 校验
版本不是 HTTP/1.0/1.1505请求行校验

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 与管线化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发