《Java 网络编程基础》9.1 HTTP/2 帧类型与流状态

从 linkd 一条连接只能串行处理请求的瓶颈出发,拆开 HTTP/2 的 9 字节帧头,逐字节解析 SETTINGS、HEADERS、DATA、RST_STREAM 与 GOAWAY,给出 Http2Frame、Http2FrameCodec 的编解码实现,并用 preface 握手把帧接进 9090 的读循环。

本节目标:能看懂一段 HTTP/2 字节流的帧边界,并写出可用的帧编解码器与 preface 握手。
适用版本:JDK 25(Temurin 25.0.5+8);只使用 java.nio 与 JDK 标准库。

承接的问题

8.3 的路由已经能把 GET /books/42 分发给业务方法,但它的并发单位是连接。一条 keep-alive 连接上,第二个请求必须等第一个响应写完才能开始;管线化允许客户端连续发,服务端仍要按序回包,队头阻塞没有消失。linkd 要同时代理 6 个上游请求时,HTTP/1.1 的做法是开 6 条 TCP 连接,每条都带一次握手、一份缓冲区和一个线程。

HTTP/2 把「连接」和「请求」拆成两层:TCP 连接只有一条,请求是连接里的 stream,每个 stream 有自己的 id、窗口和状态。帧(frame)是这两层之间唯一的传输单位,所有控制信息(设置、心跳、关连接)和业务数据(头部、消息体)都编码成帧。

linkd 只做明文 h2c,靠 prior knowledge 直接发 preface,不做 TLS ALPN;ALPN 留到 10.3。本节先把帧这一层写对,9.2 解头部,9.3 管窗口。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:9 字节帧头与长度域

每个 HTTP/2 帧都以固定的 9 字节开头,后面跟 length 个字节的载荷:

偏移宽度字段说明
03Length载荷长度,不含 9 字节头,最大 16777215
31Type帧类型,未知类型必须忽略
41Flags类型相关的标志位
54R + Stream Identifier最高位保留,低 31 位是 stream id

linkd 启动后发出的第一个 SETTINGS 帧,本机编码结果是:

00000c04000000000000030000006400040000ffff

拆开看:00000c 是长度 12;04 是 SETTINGS;00 没有标志;00000000 表示连接级;载荷是两组 6 字节的设置项,0003 00000064 是 MAX_CONCURRENT_STREAMS=100,0004 0000ffff 是 INITIAL_WINDOW_SIZE=65535。长度 12 正好是两组设置项,不含帧头,这是第一个容易写错的地方。

对应的类型定义:

package com.plume.linkd.http;

public record Http2Frame(int type, int flags, int streamId, byte[] payload) {

    public static final int HEADER_LENGTH = 9;

    public static final int DATA = 0x0;
    public static final int HEADERS = 0x1;
    public static final int PRIORITY = 0x2;
    public static final int RST_STREAM = 0x3;
    public static final int SETTINGS = 0x4;
    public static final int PUSH_PROMISE = 0x5;
    public static final int PING = 0x6;
    public static final int GOAWAY = 0x7;
    public static final int WINDOW_UPDATE = 0x8;
    public static final int CONTINUATION = 0x9;

    public static final int FLAG_END_STREAM = 0x1;
    public static final int FLAG_ACK = 0x1;
    public static final int FLAG_END_HEADERS = 0x4;
    public static final int FLAG_PADDED = 0x8;

    public Http2Frame {
        if (type < 0 || type > 0xFF) {
            throw new IllegalArgumentException("type out of range: " + type);
        }
        if (flags < 0 || flags > 0xFF) {
            throw new IllegalArgumentException("flags out of range: " + flags);
        }
        if (streamId < 0 || streamId > 0x7FFF_FFFF) {
            throw new IllegalArgumentException("streamId out of range: " + streamId);
        }
    }

    public int length() {
        return payload.length;
    }

    public boolean flag(int mask) {
        return (flags & mask) != 0;
    }
}

FLAG_ACK 和 FLAG_END_STREAM 都是 0x1,但属于不同帧类型,含义完全不同:SETTINGS 的 0x1 是确认,DATA 的 0x1 是消息体结束。标志位必须结合类型解释,不能抽成通用枚举。

编码时三个长度字节要按大端写,并且不能把负数字节直接 put 成有符号值。maxFrameSize 字段默认 16384,收到 SETTINGS_MAX_FRAME_SIZE 后更新,超过上限的帧在编码前就被拒绝:

public ByteBuffer encode(Http2Frame frame) {
    int length = frame.length();
    if (length > maxFrameSize) {
        throw new Http2ProtocolException(Http2ProtocolException.FRAME_SIZE_ERROR,
                "frame payload " + length + " exceeds " + maxFrameSize);
    }
    ByteBuffer out = ByteBuffer.allocate(Http2Frame.HEADER_LENGTH + length);
    out.put((byte) (length >>> 16)).put((byte) (length >>> 8)).put((byte) length);
    out.put((byte) frame.type()).put((byte) frame.flags());
    out.putInt(frame.streamId() & 0x7FFF_FFFF);
    out.put(frame.payload());
    out.flip();
    return out;
}

解码要处理半帧:缓冲区里不足 9 字节,或者不足「9 加 declared length」时,不能报错,也不能前进 position,要把字节留在原地等下一次读事件:

public List<Http2Frame> decode(ByteBuffer in) {
    List<Http2Frame> frames = new ArrayList<>();
    while (in.remaining() >= Http2Frame.HEADER_LENGTH) {
        int start = in.position();
        int length = ((in.get(start) & 0xFF) << 16)
                | ((in.get(start + 1) & 0xFF) << 8)
                | (in.get(start + 2) & 0xFF);
        if (length > maxFrameSize) {
            throw new Http2ProtocolException(Http2ProtocolException.FRAME_SIZE_ERROR,
                    "declared length " + length + " exceeds " + maxFrameSize);
        }
        int total = Http2Frame.HEADER_LENGTH + length;
        if (in.remaining() < total) {
            break;
        }
        ByteBuffer raw = in.slice(start, total);
        int type = raw.get(3) & 0xFF;
        int flags = raw.get(4) & 0xFF;
        int streamId = raw.getInt(5) & 0x7FFF_FFFF;
        byte[] payload = new byte[length];
        raw.get(Http2Frame.HEADER_LENGTH, payload);
        in.position(start + total);
        frames.add(new Http2Frame(type, flags, streamId, payload));
    }
    return frames;
}

这段解码和 7.2 的 FrameDecoder 是同一个套路:List<byte[]> decode(ByteBuffer in) 负责「攒够一帧就切出来,不够就原样留着」,EventLoop 每次读事件后调用一次。HTTP/2 的差异只有三个参数:长度域在偏移 0、宽 3 字节、整帧长度等于 length + 9。如果 7.2 的解码器把这三个值做成可配置项,可以直接复用它;本节没有复用,是因为 HTTP/2 还要校验 maxFrameSize、拆出 type/flags/streamId 并做流状态检查,复用只能省下十行,却要多一次数组拷贝。两者的契约保持一致,替换时读循环不需要改。

本机把三段字节拼起来喂给解码器,输出是:

type=0x4 flags=0x0 stream=0 length=12
type=0x1 flags=0x5 stream=1 length=20
type=0x7 flags=0x0 stream=0 length=11
partial frames = 0, remaining = 14
oversize: declared length 20000 exceeds 16384 (errorCode=0x6)

前 14 个字节只够一个帧头,声明长度 12,总长 21,解码器返回 0 帧并保留 14 字节;另一个帧头声明 20000,超过默认 SETTINGS_MAX_FRAME_SIZE 16384,抛 FRAME_SIZE_ERROR。

核心机制二:SETTINGS、PING 与 GOAWAY

SETTINGS 是连接级帧,stream id 必须是 0,载荷是若干 6 字节项:2 字节 id 加 4 字节值。本机解析上面那段载荷:

settings=[Setting[id=3, value=100], Setting[id=4, value=65535]]

常用设置项:

id名称合法范围与含义
1HEADER_TABLE_SIZEHPACK 动态表上限,默认 4096
2ENABLE_PUSH0 或 1,服务端推送开关
3MAX_CONCURRENT_STREAMS对端允许的并发流数量
4INITIAL_WINDOW_SIZE流级初始窗口,最大 2^31-1
5MAX_FRAME_SIZE16384 到 16777215
6MAX_HEADER_LIST_SIZE头部解压后的字节上限

收到 SETTINGS 必须回一个 ACK:type=0x4、flags=0x1、stream=0、载荷为空。ACK 本身不能再被确认,否则两端会无限互回。本机的 ACK 编码是 000000040100000000,长度 0,一眼能看出没有载荷。

解析代码只有十行,但要拒绝长度不是 6 的倍数的载荷:

public static List<Setting> parseSettings(byte[] payload) {
    if (payload.length % 6 != 0) {
        throw new Http2ProtocolException(Http2ProtocolException.FRAME_SIZE_ERROR,
                "SETTINGS payload length " + payload.length);
    }
    List<Setting> settings = new ArrayList<>();
    ByteBuffer in = ByteBuffer.wrap(payload);
    while (in.hasRemaining()) {
        int id = in.getShort() & 0xFFFF;
        long value = in.getInt() & 0xFFFF_FFFFL;
        settings.add(new Setting(id, value));
    }
    return settings;
}

getInt() 返回有符号 int,INITIAL_WINDOW_SIZE 可以到 2147483647,仍在 int 正数范围内,但窗口累加会溢出,所以这里立刻扩成 long。9.3 的窗口计算全部用 long,原因就在这里。

PING 是连接级心跳,载荷必须 8 字节,收到后原样回一个带 ACK 的 PING。GOAWAY 用来优雅关连接:载荷前 4 字节是「最后处理的 stream id」,接着 4 字节错误码,剩下是调试文本。本机编码 goaway(1, 0, "bye") 得到:

00000b0700000000000000000100000000627965

长度 11 等于 8 字节固定字段加 3 字节 bye。last-stream-id=1 的含义是「id 大于 1 的流没有处理,客户端可以安全重试」,它不是「连接已关闭」的通知,而是重试边界的声明。

Http2ProtocolException 把错误码带在身上,方便上层决定发 RST_STREAM 还是 GOAWAY。它只是一个携带 int errorCode 的 RuntimeException,构造时把错误码拼进 message,本机打印出来是 ... (errorCode=0x6) 这种形式。用到的错误码如下:

public static final int PROTOCOL_ERROR = 0x1;      // 连接级
public static final int FLOW_CONTROL_ERROR = 0x3;  // 连接级
public static final int STREAM_CLOSED = 0x5;       // 流级
public static final int FRAME_SIZE_ERROR = 0x6;    // 连接级
public static final int COMPRESSION_ERROR = 0x9;   // 连接级

错误码分两级:PROTOCOL_ERROR、FRAME_SIZE_ERROR、COMPRESSION_ERROR 等是连接级错误,只能 GOAWAY;STREAM_CLOSED 这类流级错误可以只 RST_STREAM 掉单条流。选错级别会把一个请求的错误升级成整条连接断开,这是 HTTP/2 网关最常见的放大故障。

核心机制三:HEADERS、DATA 与流状态机

HEADERS 携带 HPACK 编码的头部块,DATA 携带消息体,两者都绑定到具体 stream。HEADERS 的标志位里 END_HEADERS 表示头部块结束,END_STREAM 表示这条流没有消息体;如果头部块跨了多个帧,后续帧是 CONTINUATION,中间不允许插入任何其他类型的帧。

DATA 只有 END_STREAM 和 PADDED 两个标志。填充字节也算流量控制额度,这一点 9.3 会用到:扣窗口时用的是整个载荷长度,不是去掉 padding 之后的业务长度。

流状态可以压缩成一张表:

当前状态事件新状态
idle任一端发 HEADERSopen
open本地发 END_STREAMhalf-closed(local)
open收到 END_STREAMhalf-closed(remote)
half-closed(local)收到 END_STREAMclosed
half-closed(remote)本地发 END_STREAMclosed
任意收到或发出 RST_STREAMclosed

状态机有三个硬约束:stream id 由客户端从 1 开始按奇数分配,服务端从 2 开始按偶数分配;id 只能递增,不能复用;id 0 永远表示连接本身,DATA、HEADERS 出现在 stream 0 上就是 PROTOCOL_ERROR。

linkd 的 /books/{id} 代理会让一条连接上同时存在多个 open 流。流关闭后 id 不会回收,长时间运行的服务端要检查 nextStreamId 是否逼近 2^31-1;逼近时正确做法是发 GOAWAY 让客户端换连接,而不是回绕重用 id。

在 linkd 里落地:preface 校验与帧分发

HTTP/2 连接的第一段字节不是帧,而是 24 字节的客户端 preface:PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n。服务端必须先逐字节比对,再开始解帧;客户端则先发 preface,再发 SETTINGS。h2c 靠的就是这段固定字节,没有协商过程,所以叫 prior knowledge。

package com.plume.linkd.http;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public final class Http2ServerHandshake {

    private final Http2FrameCodec codec = new Http2FrameCodec();
    private boolean prefaceRead;
    private int maxConcurrentStreams = 100;
    private long initialWindowSize = 65_535;

    public List<Http2Frame> onReadable(ByteBuffer in) {
        List<Http2Frame> out = new ArrayList<>();
        if (!prefaceRead) {
            if (in.remaining() < Http2FrameCodec.CLIENT_PREFACE.length) {
                return out;
            }
            byte[] actual = new byte[Http2FrameCodec.CLIENT_PREFACE.length];
            in.get(actual);
            if (!Arrays.equals(actual, Http2FrameCodec.CLIENT_PREFACE)) {
                throw new Http2ProtocolException(Http2ProtocolException.PROTOCOL_ERROR,
                        "bad client preface");
            }
            prefaceRead = true;
            out.add(serverSettings());
        }
        for (Http2Frame frame : codec.decode(in)) {
            if (frame.type() == Http2Frame.SETTINGS) {
                if (frame.flag(Http2Frame.FLAG_ACK)) {
                    continue;
                }
                if (frame.streamId() != 0) {
                    throw new Http2ProtocolException(Http2ProtocolException.PROTOCOL_ERROR,
                            "SETTINGS on stream " + frame.streamId());
                }
                applySettings(frame.payload());
                out.add(Http2FrameCodec.settingsAck());
            } else if (frame.type() == Http2Frame.PING) {
                if (frame.length() != 8 || frame.streamId() != 0) {
                    throw new Http2ProtocolException(Http2ProtocolException.FRAME_SIZE_ERROR,
                            "PING length " + frame.length());
                }
                out.add(new Http2Frame(Http2Frame.PING, Http2Frame.FLAG_ACK, 0, frame.payload()));
            }
        }
        return out;
    }

    private void applySettings(byte[] payload) {
        for (Http2FrameCodec.Setting setting : Http2FrameCodec.parseSettings(payload)) {
            switch (setting.id()) {
                case 3 -> maxConcurrentStreams = (int) setting.value();
                case 4 -> initialWindowSize = setting.value();
                case 5 -> codec.maxFrameSize((int) setting.value());
                default -> {
                }
            }
        }
    }

    private Http2Frame serverSettings() {
        ByteBuffer payload = ByteBuffer.allocate(12);
        payload.putShort((short) 3).putInt(maxConcurrentStreams);
        payload.putShort((short) 6).putInt(8192);
        return new Http2Frame(Http2Frame.SETTINGS, 0, 0, payload.array());
    }
}

把 preface 和客户端 SETTINGS 拼成一个 ByteBuffer 喂进去,本机输出:

reply type=0x4 flags=0x0 00000c040000000000000300000064000600002000
reply type=0x4 flags=0x1 000000040100000000
remaining bytes = 0

第一帧是服务端自己的 SETTINGS:并发流 100,头部列表上限 8192;第二帧是对客户端 SETTINGS 的 ACK,客户端声明的 50 个并发流与 131072 的初始窗口已经在 applySettings 里读入。initialWindowSize 字段在这里只做记录;9.3 的 Http2Connection 会用对端 SETTINGS 里的同一个值初始化每条新流的发送窗口。remaining=0 说明 preface 和帧都没有被重复消费,读循环可以直接把同一个 ByteBuffer 交回 EventLoop。

这段代码只处理到握手。HEADERS 进来后要交给 9.2 的 HPACK 解码器,DATA 进来后要先扣 9.3 的窗口再交给路由;这两步都依赖本节已经校验过的 streamId 和 END_STREAM 标志。

常见错误与边界

错误一:用有符号右移读长度。

// 反例:最高字节 >= 0x80 时结果为负
int length = (in.get() << 16) | (in.get() << 8) | in.get();

byte 提升成 int 会做符号扩展,长度 0x00FF00 读出来是 -256,后续所有边界判断都失效。必须逐字节 & 0xFF,或者用 ByteBuffer.getInt 后自己掩掉高位。

错误二:把 length 当成整帧长度。 帧头声明 12 字节,只跳过 12 字节,下一帧就会从载荷中间开始解析,type 读成 0x64 之类的随机值,表现为「连接无故 GOAWAY」。判断整帧是否到齐要用 HEADER_LENGTH + length,7.2 里长度域包含头和不包含头两种协议都常见,写解码器时要把这一点固定成常量。

错误三:对未知帧类型抛异常。 RFC 9113 明确要求忽略未知类型。前面的 onReadable 只对 SETTINGS 和 PING 分支处理,其余类型既不解析也不报错,等于丢弃;如果加一个 default 抛 PROTOCOL_ERROR,对端只要发一个扩展帧就能把连接打断。

错误四:SETTINGS ACK 带载荷,或者在非 0 流上发 SETTINGS。 两种都是 PROTOCOL_ERROR。ACK 的载荷长度必须为 0;SETTINGS 的 stream id 必须为 0。

错误五:把 HEADERS 的 END_STREAM 当成连接结束。 它只表示这条流没有消息体,连接还能继续开新流。把流级标志当连接级用,会让一个 GET /healthz 之后的连接全部被关闭。

边界:本实现只覆盖教学子集。 不支持 PUSH_PROMISE、PRIORITY 帧和优先树,不支持 CONTINUATION:HEADERS 没有 END_HEADERS 时直接以 PROTOCOL_ERROR 关闭连接,因为 HPACK 动态表是连接级状态,跳过半个头部块会让后续所有流解错。真实客户端(浏览器、gRPC)会发 CONTINUATION,所以这套实现不能对外声称完整兼容 HTTP/2。

小结

  • HTTP/2 帧头固定 9 字节,length 只算载荷,不含头;最大帧长默认 16384。
  • SETTINGS 是 6 字节项数组,必须 ACK 且 ACK 无载荷;窗口相关值要用 long 承接。
  • stream id 0 是连接级,客户端用奇数、服务端用偶数,只能递增不能复用。
  • HEADERS 带 END_HEADERS 与 END_STREAM,DATA 只有 END_STREAM 与 padding 标志,两者语义不能互换。
  • 未知帧类型必须忽略;连接级错误发 GOAWAY,流级错误发 RST_STREAM。
  • Http2FrameCodec 与 7.2 的 FrameDecoder 契约相同,差别只在长度域位置和帧头校验。

帧边界稳定之后,HEADERS 的载荷还是一串无法直接读的字节。下一节把 HPACK 拆开:静态表、动态表、整数编码和 Huffman 解码,并用 RFC 7541 的测试向量验证。

阅读导航:上一节:8.3 路由、静态文件与错误响应 · 下一节:9.2 HPACK 与头部压缩 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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