本节目标:能看懂一段 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 个字节的载荷:
| 偏移 | 宽度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 3 | Length | 载荷长度,不含 9 字节头,最大 16777215 |
| 3 | 1 | Type | 帧类型,未知类型必须忽略 |
| 4 | 1 | Flags | 类型相关的标志位 |
| 5 | 4 | R + 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 | 名称 | 合法范围与含义 |
|---|---|---|
| 1 | HEADER_TABLE_SIZE | HPACK 动态表上限,默认 4096 |
| 2 | ENABLE_PUSH | 0 或 1,服务端推送开关 |
| 3 | MAX_CONCURRENT_STREAMS | 对端允许的并发流数量 |
| 4 | INITIAL_WINDOW_SIZE | 流级初始窗口,最大 2^31-1 |
| 5 | MAX_FRAME_SIZE | 16384 到 16777215 |
| 6 | MAX_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 | 任一端发 HEADERS | open |
| open | 本地发 END_STREAM | half-closed(local) |
| open | 收到 END_STREAM | half-closed(remote) |
| half-closed(local) | 收到 END_STREAM | closed |
| half-closed(remote) | 本地发 END_STREAM | closed |
| 任意 | 收到或发出 RST_STREAM | closed |
状态机有三个硬约束: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 与头部压缩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。