
上周调一个 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 常见帧类型速查不同帧类型承载不同职责我整理了一个常用的速查表方便后面实战对照类型编号名称作用常见 flags0x0DATA传输请求/响应体END_STREAM、PADDED0x1HEADERS传输 HTTP 头部块END_STREAM、END_HEADERS、PADDED、PRIORITY0x2PRIORITY设置流的优先级无0x3RST_STREAM终止某个流无0x4SETTINGS连接级参数协商ACK0x5PUSH_PROMISE服务端推送END_HEADERS、PADDED0x6PING心跳和 RTT 测量ACK0x7GOAWAY优雅关闭连接或报错无0x8WINDOW_UPDATE流量控制窗口更新无0x9CONTINUATION继续传输上一帧未完成的头部块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 类对应帧类型关键属性/方法DataFrameDATAdata字段保存 bodyflags 可加 END_STREAMHeadersFrameHEADERSdata保存 HPACK 编码后的头部块需要配合 END_HEADERSSettingsFrameSETTINGSsettings字典保存键值对例如SettingsFrame.ENABLE_PUSHPingFramePINGopaque_data8 字节数据GoAwayFrameGOAWAYlast_stream_id、error_code、additional_dataWindowUpdateFrameWINDOW_UPDATEwindow_increment表示增加的窗口大小ContinuationFrameCONTINUATIONdata保存剩余头部块这些类的 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.10hyperframe 的版本是 6.x。老版本 API 可能略有差异但核心概念都一样照着下面的示例通常不会出问题。3.2 构造一个 DATA 帧并抓出它的二进制先来一个最简单的场景构造一个 DATA 帧里面放一串文本然后看看它在网络上的真实形态。from hyperframe.frame import DataFrame frame DataFrame(stream_id1) frame.data bhello 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类型 0x00flags 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 为 1body 是bhello。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初始窗口大小为 65535from hyperframe.frame import SettingsFrame s SettingsFrame(stream_id0) s.settings[SettingsFrame.MAX_CONCURRENT_STREAMS] 100 s.settings[SettingsFrame.INITIAL_WINDOW_SIZE] 65535 client_preface bPRI * 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 lenhyperframe 可能不会帮你纠错。最好的方式是不用 PADDED本地调试就别给自己加戏。5. 我的最终建议和一点心得5.1 hyperframe 的上限和下限hyperframe 只做帧层不做连接状态机不做 HPACK不做流控策略。它的定位就是“把 HTTP/2 帧变成 Python 对象”。别指望它替你判断能不能在这个流上发 RST_STREAM也别指望它自动处理 SETTINGS 协商后的窗口变化这些属于上层协议栈的职责。如果你要造一个完整的 HTTP/2 客户端建议直接在h2之上写业务再往下才轮到 hyperframe。反过来如果你需要学习的恰好是帧层、想深入理解协议细节hyperframe 的源码是绝佳教材。它的源码不算长帧类型分装得很清晰我通读一遍之后对 HTTP/2 的帧格式理解比看文档强得多。5.2 建议的调试组合我现在的调试套路是三件套hyperframehpackWireshark。用 hyperframe 构造和解析帧用 hpack 做头部块的编解码用 Wireshark 抓包验证。本地可以起一个支持 HTTP/2 cleartext 的服务端或者用 Python 的hypercorn跑 h2然后自己写个客户端把帧发出去再抓 loopback 接口来看。调帧格式的时候最好的办法是先抓一段标准库产生的流量导成 hex再写脚本用Frame.parse逐帧还原对照 Wireshark 解析结果。只要两边输出的帧类型、flags、流 ID 一致基本就可以放心了。5.3 最后再分享一个小技巧如果你在构造帧的时候拿不准某个 flag 对应的比特位可以直接打印frame.flags的字符串表示看它是不是你预期的那几个。比如frame DataFrame(stream_id1) 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别在语义层面犯浑。希望这篇文章能让你少走点弯路。