上周调一个 HTTP/2 客户端的时候,我被帧这玩意儿整得头大。抓包工具里明明能看到服务端发来的一串串十六进制,落到代码里却要自己一截一截切 length、看 type、兑 flags,稍不小心就把整个流给切错位。后来我把 hyperframe 这个库老老实实用了两天,顿时觉得之前的手写解析器基本白写了。今天就聊聊 hyperframes——准确说是 hyperframe 这个 Python 库在 HTTP/2 帧处理上的实战用法,以及我在用它拆帧、组帧过程中踩过的一堆坑。
这篇文章适合谁?如果你正在写 HTTP/2 客户端/服务端的底层栈,或者被 Wireshark 里的二进制帧搞到怀疑人生,再或者只是好奇 HTTP/2 的帧到底怎么在网络里流动,这篇文章应该能让你少探几段弯路。
1. HTTP/2 帧:我为什么被它折腾了好几天
1.1 二进制分帧到底解决了个什么问题
HTTP/1.1 时代,请求和响应都是纯文本,用空行分隔 header 和 body。看起来挺直观,但性能上有个很要命的地方:一个 TCP 连接同一时间只能处理一个请求,这就是著名的队头阻塞。后来大家想了个办法,叫做多路复用,也就是在一个连接上同时跑很多个请求。可如果还是用文本分隔符来切数据,根本没法分清一段字节到底属于哪个请求、哪部分内容。
HTTP/2 的答案是二进制分帧。所有数据都被切成一格一格的帧(frame),每帧都有明确的长度、类型、所属流 ID,代码拿到字节流后只需要按照固定规则去切就能还原出一个个逻辑单元。流和帧的关系,你可以想象成高速公路上的卡车编队:一条连接是一条路,每一辆卡车是一个流,卡车里的集装箱就是帧。车头写着目的地(stream_id),车厢上贴着货单(type 和 flags),这样即使很多车混在一起,也能准确分流到各自的卸货点。
1.2 帧头那 9 个字节,藏着整个协议的心跳
每一帧最前面是 9 字节的固定头,之后才是最长 16 MB 的 payload。9 字节的结构是这样的:
- 第 0~2 字节:24 位无符号整数,表示后面 payload 的长度,不包含这 9 字节本身。
- 第 3 字节:帧类型,常见的有 DATA(0x0)、HEADERS(0x1)、SETTINGS(0x4)、PING(0x6)、GOAWAY(0x7)、WINDOW_UPDATE(0x8) 等。
- 第 4 字节:flags,按位表示不同附加信息,比如 END_STREAM、END_HEADERS、ACK 等。
- 第 5~8 字节:31 位流 ID,最高位是保留位,必须为 0。流 ID 为 0 表示这条帧属于连接本身,而不是某个请求流。
长度字段只算 payload,这个细节看着简单,但实际写解析的人很容易栽跟头。我最早手写解析器的时候,傻乎乎地把帧头也加进了 length,导致后面所有帧全部错位,一帧错、帧帧错,排查了很久才意识到是自己连最基础的定义都没搞对。
如果你要处理 HTTP/2 的原始 TCP 流,本质上就是循环读 9 字节头,算出 payload 长度,再继续读 payload,拼成一帧后解析。但这里面还有粘包、半包、TCP 缓冲、Nagle 算法等问题,代码写起来很啰嗦。hyperframe 就是帮我把这些脏活儿收拢起来的那个工具。
1.3 常见帧类型速查
不同帧类型承载不同职责,我整理了一个常用的速查表,方便后面实战对照:
| 类型编号 | 名称 | 作用 | 常见 flags |
|---|---|---|---|
| 0x0 | DATA | 传输请求/响应体 | END_STREAM、PADDED |
| 0x1 | HEADERS | 传输 HTTP 头部块 | END_STREAM、END_HEADERS、PADDED、PRIORITY |
| 0x2 | PRIORITY | 设置流的优先级 | 无 |
| 0x3 | RST_STREAM | 终止某个流 | 无 |
| 0x4 | SETTINGS | 连接级参数协商 | ACK |
| 0x5 | PUSH_PROMISE | 服务端推送 | END_HEADERS、PADDED |
| 0x6 | PING | 心跳和 RTT 测量 | ACK |
| 0x7 | GOAWAY | 优雅关闭连接或报错 | 无 |
| 0x8 | WINDOW_UPDATE | 流量控制窗口更新 | 无 |
| 0x9 | CONTINUATION | 继续传输上一帧未完成的头部块 | END_HEADERS |
实际调试时,最常打交道的是 HEADERS、DATA、SETTINGS、WINDOW_UPDATE 和 CONTINUATION。前两个负责业务数据,后面三个负责连接维护和流量控制。hyperframe 对以上所有类型都有对应类,不需要自己造轮子。
2. hyperframe 是做什么的,以及它和 h2 的关系
2.1 从 Frame 基类看库的设计思路
hyperframe 是 python-hyper 生态里的底层库,专门负责 HTTP/2 帧的构造和解析。它的上层还有一个更完整的 HTTP/2 协议栈库叫 h2,负责连接状态机、流管理、HPACK 编解码等。如果你只想做帧层操作,单独用 hyperframe 就够了;如果你想写一个完整的客户端,那 h2 会把 hyperframe 包在内部使用。
hyperframe 的核心是一个Frame基类。它维护了三个最基本的东西:stream_id、flags和body。所有具体帧类型都继承这个基类,再按协议重写parse_body和serialize_body方法。设计上非常干净,读源码也不累。
Frame基类提供了serialize()方法,调用后会把 body 长度、类型、flags、流 ID 按照 9 字节头 + body 的顺序拼好,返回一个完整的 bytes 对象。同时它也有一个类方法Frame.parse(),传给它一个字节缓冲区,它返回解析后的帧对象和实际消费的字节数。返回消费长度这个设计特别实用,因为 TCP 流里可能同时连着来了好几帧,你一帧一帧切,靠的就是这个返回值来推进游标。
2.2 常用帧类型在库里的实现
hyperframe 对每种帧都提供了独立类,命名基本都是协议名直接去掉下划线再拼上 Frame。我经常用的几个映射关系如下:
| hyperframe 类 | 对应帧类型 | 关键属性/方法 |
|---|---|---|
DataFrame | DATA | data字段保存 body,flags 可加 END_STREAM |
HeadersFrame | HEADERS | data保存 HPACK 编码后的头部块;需要配合 END_HEADERS |
SettingsFrame | SETTINGS | settings字典保存键值对,例如SettingsFrame.ENABLE_PUSH |
PingFrame | PING | opaque_data8 字节数据 |
GoAwayFrame | GOAWAY | last_stream_id、error_code、additional_data |
WindowUpdateFrame | WINDOW_UPDATE | window_increment表示增加的窗口大小 |
ContinuationFrame | CONTINUATION | data保存剩余头部块 |
这些类的 flags 用法很有趣。在 hyperframe 里,flags不是一个普通整数,而是一个集合对象。你可以直接frame.flags.add('END_STREAM')来设置 flag,也可以判断'END_HEADERS' in frame.flags。这比手写按位与直观很多,也避免了一堆魔法数字散落在业务代码里。
不过你要注意,集合里的字符串 flag 名称在序列化时会被转换成对应比特位。如果你扩展自定义帧,需要给flag_enum之类的东西注册映射关系。这个细节等你自己改库源码时就会发现,别慌。
3. 实操:用 hyperframe 构造、解析一帧
3.1 环境准备和最小依赖
先装库,就一个包:
pip install hyperframe如果只是玩帧层,装它一个就够了。想试 HPACK 编码,还需要装hpack。这样可以手工把 HTTP header 编码成 HEADERS 帧的样子,再塞给 hyperframe。
pip install hpack我用的是 Python 3.10,hyperframe 的版本是 6.x。老版本 API 可能略有差异,但核心概念都一样,照着下面的示例通常不会出问题。
3.2 构造一个 DATA 帧并抓出它的二进制
先来一个最简单的场景:构造一个 DATA 帧,里面放一串文本,然后看看它在网络上的真实形态。
from hyperframe.frame import DataFrame frame = DataFrame(stream_id=1) frame.data = b"hello hyperframes" frame.flags.add("END_STREAM") raw = frame.serialize() print(len(frame.data)) # 16 字节 payload print(raw.hex())serialize()返回的 bytes 对象开头的 9 字节就是帧头。你可以用前面讲的帧头格式自己拆开验证一下:
- 前 3 字节是 payload 长度 0x10,也就是 16。
- 第 4 字节是类型 0x00,表示 DATA 帧。
- 第 5 字节是 flags 0x01,表示 END_STREAM。
- 第 6~9 字节是流 ID 1。
这样一个 frame 就封装好了。如果你在写轻量客户端,可以直接把它写到 socket 连接里发给服务端。
3.3 解析一段原始帧数据
构造只是半边天,解析才是日常高频操作。假设你在抓包时看到这么一串字节:
00000500010000000168656c6c6f一眼看过去,长度为 5,类型 0x00,flags 0x01,流 ID 1,最后 5 字节是hello。用 hyperframe 解析很简单:
from hyperframe.frame import Frame raw = bytes.fromhex("00000500010000000168656c6c6f") frame, consumed = Frame.parse(raw) print(type(frame).__name__) print(frame.stream_id) print(frame.data) print(consumed)输出结果会显示这是一个DataFrame,流 ID 为 1,body 是b'hello'。consumed是 14,也就是帧头 9 字节加 payload 5 字节。这个返回值在循环处理多个连续的帧时特别好用,你可以这样写:
def parse_frames(buffer): frames = [] offset = 0 while offset < len(buffer): frame, consumed = Frame.parse(buffer[offset:]) frames.append(frame) offset += consumed return frames是不是很清爽?如果我自己硬解,至少得写 30 行长度判断逻辑,而且还不一定处理全各种边界情况。用 library 就是香。
3.4 手动发一个 SETTINGS 帧做连接握手
HTTP/2 连接建立后,客户端必须先发送一个 24 字节的 Magic 字符串,然后紧接着发一个 SETTINGS 帧。Magic 字符串固定为:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n这段字符串是协议规定的连接序言,服务端看到就会认你做 HTTP/2 客户端。之后的 SETTINGS 帧可以用 hyperframe 构造。比如设置允许的最大并发流数为 100,初始窗口大小为 65535:
from hyperframe.frame import SettingsFrame s = SettingsFrame(stream_id=0) s.settings[SettingsFrame.MAX_CONCURRENT_STREAMS] = 100 s.settings[SettingsFrame.INITIAL_WINDOW_SIZE] = 65535 client_preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" payload = client_preface + s.serialize() # 然后把 payload 写入你的 TCP socket这里有两个小细节值得注意。第一,SETTINGS 帧的流 ID 必须是 0,因为它是连接级的参数,不属于任何请求流。第二,如果某个 SETTINGS 帧是 ACK 响应,flags 里要带上 ACK,且 payload 长度为 0。你不能一边发 ACK 一边带设置项,这是协议明确禁止的。hyperframe 不会替你校验这些语义,它只保证你构造出来的是一个合法格式的帧,业务规则还得自己守。
4. 踩坑记录:这些细节文档里不会写
4.1 长度字段算错,所有帧全部错位
这个坑我在文章开头提过,但值得单独拿出来再说一遍。HTTP/2 帧头里的长度只算 payload,不算帧头本身。想象一个 DATA 帧 payload 是 16 字节,那么前 3 字节应该是 0x10,不是 0x13。如果写解析器时误把 9 字节头也加进去,第一个帧解析完会多读 9 个字节,后面所有帧都会失真。更隐蔽的是,你解析一个短帧可能没感觉,直到某个长帧把后面一连串数据都吞掉,才会发现整个连接已经乱了。
用 hyperframe 之后,这种低级错误基本不会再发生。它对帧头的解析和 body 长度的取用都是内部完成的,你只要保证给它的字节流是完整的。但要注意,如果你从 socket 里一次读一大块数据,里面可能包含半帧或者多帧,正确姿势是先读 9 字节再读对应长度的 body,或者直接用一个缓冲器累积数据,配合Frame.parse的consumed返回值慢慢切。
4.2 别把 END_HEADERS 和 END_STREAM 混为一谈
刚开始学 HTTP/2 的人很容易把 HEADERS 帧上的两个 flag 看混。END_STREAM表示这个流结束后不会再发送数据,是业务层面的结束;END_HEADERS表示这一组头部块已经全部传输完成,是协议层面的结束。
举个例子:服务端返回一个响应,HEADERS 帧带END_HEADERS表示头部结束了,但是 body 还没完,后面还需要 DATA 帧传内容。只有最后一个 DATA 帧才带END_STREAM。如果 HEADERS 帧同时带END_HEADERS和END_STREAM,那就表示这是个没有 body 的响应,比如 204 或者某些 304。
我在实践里犯过一个错误:构造客户端请求时忘了在 HEADERS 帧上加END_STREAM,服务端就一直傻等请求体,搞得请求挂起。后来检查抓包才发现,HEADERS 帧里只有END_HEADERS,没有END_STREAM,等于告诉服务端“我还有后续数据要发,先别急着响应”。这俩 flag 各管各的,不能想当然。
4.3 stream_id 的保留位和奇偶规则
流 ID 的 32 位里,最高位是保留位,必须为 0,所以实际有效值只有 31 位,范围是 0~2^31-1。另外,客户端发起的流 ID 必须是奇数,服务端发起的流 ID 必须是偶数,0 留给连接级帧。这个设计是为了避免客户端和服务端各自发起的流 ID 空间冲突。
如果你直接用 hyperframe 构造帧,给它一个偶数 stream_id 也不会报错,因为 hyperframe 不做协议合规校验。但不是规范的东西就危险。我和同事联调时,有一侧代码把请求流 ID 写成了 0,服务端直接 GOAWAY。流 ID 为 0 的连接级帧只能承载 SETTINGS、PING、GOAWAY 这类连接管理消息,你是不能用它发 HEADERS 或 DATA 的,这是协议硬性约束。
4.4 CONTINUATION 帧和头部大请求
HTTP/2 里一个 HEADERS 帧最多能承载的 payload 长度受限于双方协商的 MAX_FRAME_SIZE,默认是 16384(这个值是最小值,可以用 SETTINGS 帧的 MAX_FRAME_SIZE 提到最大 16777215)。如果 HPACK 编码后的头部块超过这个长度,协议规定必须拆到多个 CONTINUATION 帧里继续传。
这个逻辑挺绕的:HEADERS 帧不带END_HEADERS,然后后续连续 N 个 CONTINUATION 帧,直到最后一个 CONTINUATION 帧带上END_HEADERS,才算完整头部块结束。中途不能插入其他类型的帧,必须是连续的头部块序列。
hyperframe 里 CONTINUATION 帧的类比较简单,就是存一段data。但你用的时候要注意,它不会自动帮你拼接头部块。你需要自己维护一个缓冲区,把 HEADERS 和后续的 CONTINUATION 都收进来,直到看到END_HEADERS为止,再统一交给 HPACK 解码器。我一开始以为 hyperframe 会处理这种拼装,结果 debug 了半天发现HeadersFrame.data只是第一段,后面几段全在 CONTINUATION 里。
4.5 PADDED 标志带来的长度陷阱
DATA 和 HEADERS 帧都支持 PADDED flag。当 PADDED 置位时,payload 开头会多出一个 1 字节的 pad length,表示末尾有多少填充字节,真实数据长度要扣除这些填充。填充的目的是混淆流量特征,防止基于包大小的流量分析。
如果你手写解析器,这个细节非常容易漏。漏掉的后果就是你会把填充字节当成业务数据,导致 body 多出一堆脏字节。hyperframe 的parse_body内部已经处理了 pad length,所以你读DataFrame.data拿到的已是去填充后的干净数据。但序列化时,如果你手动设置了PADDEDflag,又没正确设置 pad len,hyperframe 可能不会帮你纠错。最好的方式是不用 PADDED,本地调试就别给自己加戏。
5. 我的最终建议和一点心得
5.1 hyperframe 的上限和下限
hyperframe 只做帧层,不做连接状态机,不做 HPACK,不做流控策略。它的定位就是“把 HTTP/2 帧变成 Python 对象”。别指望它替你判断能不能在这个流上发 RST_STREAM,也别指望它自动处理 SETTINGS 协商后的窗口变化,这些属于上层协议栈的职责。
如果你要造一个完整的 HTTP/2 客户端,建议直接在h2之上写业务,再往下才轮到 hyperframe。反过来,如果你需要学习的恰好是帧层、想深入理解协议细节,hyperframe 的源码是绝佳教材。它的源码不算长,帧类型分装得很清晰,我通读一遍之后,对 HTTP/2 的帧格式理解比看文档强得多。
5.2 建议的调试组合
我现在的调试套路是三件套:hyperframe+hpack+Wireshark。用 hyperframe 构造和解析帧,用 hpack 做头部块的编解码,用 Wireshark 抓包验证。本地可以起一个支持 HTTP/2 cleartext 的服务端,或者用 Python 的hypercorn跑 h2,然后自己写个客户端把帧发出去,再抓 loopback 接口来看。
调帧格式的时候,最好的办法是先抓一段标准库产生的流量,导成 hex,再写脚本用Frame.parse逐帧还原,对照 Wireshark 解析结果。只要两边输出的帧类型、flags、流 ID 一致,基本就可以放心了。
5.3 最后再分享一个小技巧
如果你在构造帧的时候拿不准某个 flag 对应的比特位,可以直接打印frame.flags的字符串表示,看它是不是你预期的那几个。比如:
frame = DataFrame(stream_id=1) frame.flags.add("END_STREAM") print(frame.flags) # {END_STREAM}这个输出能帮你直观确认 flag 有没有设置成功。但更要盯紧的是序列化后帧头里的 type 和 flags 字节,用raw.hex()打印出来,再拿协议规范对照一遍。我一般会写一个小断言,比如 DATA 帧的raw[3] == 0x00、raw[4] & 0x01 == 1,把关键字段都验证一遍,跑通一次之后后面切换场景就安心多了。
HTTP/2 这东西,看着全是二进制,实际摸清套路后也就是一个 9 字节头加不同类型 body 的组合游戏。hyperframe 帮你把底层的体力活都包了,剩下要做的就是理解流、理解 flag,别在语义层面犯浑。希望这篇文章能让你少走点弯路。