ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解 HTTP/2 帧:用 hyperframes 实现二进制分帧编解码

深入理解 HTTP/2 帧:用 hyperframes 实现二进制分帧编解码 我第一次在抓包工具里看到 HTTP/2 流量时第一反应是这玩意儿怎么读文本协议时代报文头还能用GET /index.html HTTP/1.1一眼扫出来HTTP/2 换成一堆二进制帧后不借助工具根本不知道谁是谁。后来我在用 Python 写本地代理需要解析上游服务的 HTTP/2 响应才真正把帧这块啃明白。HTTP/2 的最小通信单位是帧而hyperframes这套 Python 库恰好把帧的编解码封装得非常干净。它做的就两件事把原始字节还原成带语义的 Frame 对象或者把 Frame 对象编码成符合协议的字节流。这篇博文就以hyperframes为主线从协议为什么这么设计开始一路讲到帧头解析、十种帧类型、编解码实操和排查经验适合正在写网关、做抓包分析或者想把 HTTP/2 协议栈搞透彻的读者。严格说PyPI 上的包名是单数hyperframe社区里说hyperframes通常指的是这一整套帧对象和编解码机制。它是 python-hyper 生态里负责“二进制分帧层”的底层库和hpack、h2各有分工。理解了这个库你以后再看到抓包软件里的 HTTP/2 帧就不会只觉得是一团字节而是能直接对应到协议规范的每一处设计。1. 为什么是 HTTP/2 帧协议设计和 hyperframes 的定位1.1 从一条连接处理一个请求到一条连接承载所有请求HTTP/1.1 时代最头疼的问题之一就是队头阻塞。Keep-Alive 允许连接复用但同一时刻一个连接上只能有一个请求在等待响应。只要前一个响应慢一点后面的请求全得排队。浏览器为了提速只能拼命开多路 TCP 连接每一路都有握手开销连接多了还占用系统资源。HTTP/2 的设计思路跟 HTTP/1.1 完全不同它不把一次请求看作一段可读文本而把所有通信都切碎成大小不等的“帧”在同一连接上交错传输。每个请求对应一个流流由若干帧组成帧则是搬运数据的最小单位。这样一条 TCP 连接就可以承载几十上百个并发请求排序、取消、流控都在帧层面完成。分帧层是 HTTP/2 和 HTTP/1.1 最大的分水岭。一个完整的 HTTP/2 消息可能被拆成 HEADERS 帧、DATA 帧、CONTINUATION 帧而且这些帧可以和其他流的帧插在一起发送。接收方必须根据帧头里的 stream ID 判断这段数据属于哪个请求再根据帧类型决定如何处理。这个设计解决了队头阻塞代价是协议复杂度和调试难度都上来了。如果你打算写代理、网关、抓包分析工具或者只是想手动构造一个 HTTP/2 请求就必须先理解帧。1.2 hyperframes 在 Python 生态里解决哪一环Python 生态里做 HTTP/2 的库不少但分工差异很大。hyper是完整客户端h2是协议状态机hpack负责 HTTP/2 头压缩hyperframe则只负责帧本身。hyperframes 这个库不做 socket 连接不做 HPACK 头压缩不维护流状态它甚至不能帮你发一个完整的请求。它能做的就三件事定义各种帧类型对应的 Python 对象把帧对象序列化成网络字节序的二进制数据把二进制数据增量还原成帧对象。这个单一职责设计在实际使用中非常舒服。调试时你不需要引入完整客户端库只需要拿到网络上的一小段字节喂给FrameDecoder就能把帧头、flags、stream ID、payload 完整还原出来。如果你在写底层工具不想被高层语义干扰hyperframes 就是最合适的构建块。它的目标用户不是“要快速发一个 HTTP/2 请求”的人而是“需要理解、构造、检查 HTTP/2 帧”的人。后面所有实操都围绕这个定位展开。2. 核心帧结构拆解从 9 字节帧头到十种帧类型2.1 9 字节帧头怎么读HTTP/2 的每个帧都有一个固定的 9 字节帧头后面跟着可变长度的 payload。帧头结构看起来简单但每个字段的约束都很严格。前 3 字节按网络序组成一个 24 位无符号整数表示 payload 长度。因为是 24 位理论上最大是 16MB-1但实际帧长度还受SETTINGS_MAX_FRAME_SIZE限制默认值是 16384 字节。第 4 字节是帧类型从 0 到 9 有十种标准类型大于等于 10 的留给扩展帧。第 5 字节是 flags不同帧类型对 flags 位的解释不同。最后 4 字节里最高 1 位是保留位发送时必须是 0低 31 位是 stream ID。读帧头时最容易栽在长度上。很多人以为stream ID是 4 字节普通整数实际上最高位不能参与 stream ID 计算否则读到带保留位的值会产生偏差。另一个容易忽略的是网络序也就是大端字节序。手动拼帧时如果按本机小端序处理编出来的帧头长度、类型、stream ID 全都会错。这也是为什么我强烈建议不要手动拼字节直接用FrameEncoder就算你只是想学习也先看看它生成的字节长什么样再对照协议文档验证。2.2 十种帧类型一张表标准 HTTP/2 帧类型就十种每一种负责一类语义。我在下面列了一张速查表重点标出典型 flags 和 stream ID 要求方便抓包时快速定位。类型值帧类型典型用途常用 flagsstream ID 要求0x0DATA传输请求或响应体END_STREAM(0x01)、PADDED(0x08)必须是有效流0x1HEADERS传输 header block 片段END_STREAM、END_HEADERS(0x04)、PADDED、PRIORITY(0x20)客户端发起流为奇数服务端推送流为偶数0x2PRIORITY调整流的优先级无必须是有效流0x3RST_STREAM终止单个流无必须是有效流且不能是 00x4SETTINGS连接参数协商ACK(0x01)必须为 00x5PUSH_PROMISE服务端推送预告END_HEADERS、PADDED必须为 0且 payload 里有 promised stream ID0x6PING心跳与往返时延测量ACK(0x01)必须为 00x7GOAWAY连接优雅关闭无必须为 00x8WINDOW_UPDATE流量控制窗口更新无可以是 0也可以是具体流0x9CONTINUATION继续发送被拆分的 header blockEND_HEADERS(0x04)必须与前面的 HEADERS/PUSH_PROMISE 同流这张表看起来简单实际使用时还要注意组合规则。比如 HEADERS 帧的 END_HEADERS 没设置时后面必须跟同 stream ID 的 CONTINUATION 帧中间不能插其他流的数据。SETTINGS 帧收到后必须回一个 ACKPING 帧收到后也必须回 ACK这两条规则如果没做对很容易被对端直接断开连接。2.3 帧如何串联流生命周期理解了单帧还要把帧放回流里看。一个典型的 HTTP/2 请求流是这样开始的客户端用HEADERS帧携带 HPACK 压缩后的请求头同时带上一个新建的奇数 stream ID。如果请求体较大客户端再发一个或多个DATA帧最后一个 DATA 帧或 HEADERS 帧上通常带上 END_STREAM 标志表示这个方向的数据发送完毕。服务端处理完后用同一个 stream ID 发回 HEADERS 和 DATA其中最后一个帧也带 END_STREAM。两端都发完 END_STREAM流就进入关闭状态。运行中还可以看到 RST_STREAM、WINDOW_UPDATE、PING、GOAWAY 这些控制帧。RST_STREAM 用来提前终止一个流比如请求被取消WINDOW_UPDATE 用来告知对端“我的接收窗口扩大了”是流控机制的一部分PING 用于空跳检测和 RTT 估算GOAWAY 则在连接要优雅关闭时出现它告诉对端“我不会再处理新流了”。理解帧的顺序比背每个帧头字段更有价值因为协议错误大多发生在“允许的帧顺序之外”。3. 实操用 hyperframes 解码和构造帧3.1 安装与第一段 SETTINGS 帧环境准备很简单直接安装python -m pip install hyperframe装完先做一个最简单的编解码验证。构造一个 SETTINGS 帧设置两个连接参数然后编码成字节再解码回来。这一步能快速确认库的 API 和协议行为。from hyperframe.frame import SettingsFrame, FrameEncoder, FrameDecoder settings SettingsFrame(stream_id0) settings.settings { SettingsFrame.SETTINGS_MAX_CONCURRENT_STREAMS: 100, SettingsFrame.SETTINGS_INITIAL_WINDOW_SIZE: 65535, } encoder FrameEncoder() raw encoder.encode(settings) print(len(raw)) print(raw.hex())这段代码生成的帧总长度是 21 字节其中 9 字节帧头12 字节 payload。SETTINGS 帧的每个参数项占 6 字节前 2 字节是参数 ID后 4 字节是值。SETTINGS_MAX_CONCURRENT_STREAMS的 ID 是 0x0003SETTINGS_INITIAL_WINDOW_SIZE的 ID 是 0x0004。帧头里能看到长度字段是00000c类型是04stream ID 是00000000完全符合 SETTINGS 帧只能在连接级别发送的规范。接着用解码器还原decoder FrameDecoder() decoder.add_data(raw) frame decoder.get_frame() print(type(frame).__name__) print(frame.stream_id) print(frame.settings)这时的frame就是一个SettingsFrame对象frame.settings能直接拿到刚才设置的参数字典。FrameDecoder内部会缓存不完整的字节等积累到足够长度时才返回一个完整帧这一点在真实网络环境中非常关键。3.2 构造 HEADERS 和 DATA 帧SETTINGS 帧只是热身构造 HEADERS 和 DATA 帧才是真正接近业务的地方。DATA 帧的 payload 就是原始字节没有任何压缩最容易验证。下面构造一个带 END_STREAM 标志的 DATA 帧from hyperframe.frame import DataFrame, FrameEncoder encoder FrameEncoder() data_frame DataFrame(stream_id1, databhello, hyperframes) data_frame.flags 0x01 # END_STREAM raw encoder.encode(data_frame) print(raw.hex())这个帧的 stream ID 是 1flag 是 0x01。因为长度足够短所以帧头长度字段是000012type 是00payload 就是hello, hyperframes这 18 个字节。如果要发送更大的请求体只需把 data 换成长字节串帧头长度字段会自动变化。HEADERS 帧就要复杂一些因为它的 payload 是 HPACK 压缩后的 header block不是明文。hyperframes 本身不负责压缩你需要配合hpack库from hpack import Encoder as HpackEncoder from hyperframe.frame import HeadersFrame hpack_encoder HpackEncoder() header_block hpack_encoder.encode([ (b:status, b200), (bcontent-type, btext/plain), ]) headers HeadersFrame(stream_id1, dataheader_block) headers.flags 0x04 # END_HEADERS raw encoder.encode(headers)这里header_block是 HPACK 压缩后的字节串HeadersFrame(stream_id1, dataheader_block)把它当作 HEADERS 帧的 payload。设置END_HEADERS表示这个完整的 header block 已经发完不需要 CONTINUATION 帧。实际 HTTP/2 通信中header block 可能很大协议允许把它拆成多个 HEADERS/CONTINUATION 帧但每个帧必须保证同一个 stream ID中间不能插别的流。3.3 在 TCP 字节流里还原真实帧序列很多人在学 HTTP/2 时会遇到一个现实问题TCP 是字节流没有消息边界。一次recv返回的数据里可能包含一半帧也可能包含好几个完整帧。用 hyperframes 的正确姿势是“喂数据 循环取帧”。下面是一段模拟 HTTP/2 客户端和服务端交互的骨架代码。这里假设你已经通过 TLS 握手建立连接并且对端是支持 HTTP/2 的服务端import socket import ssl from hyperframe.frame import FrameDecoder, FrameEncoder, SettingsFrame host your-http2-server.example ctx ssl.create_default_context() raw_sock socket.create_connection((host, 443)) tls_sock ctx.wrap_socket(raw_sock, server_hostnamehost) # HTTP/2 connection preface 是固定字符串 tls_sock.sendall(bPRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) # 客户端先发 SETTINGS encoder FrameEncoder() client_settings SettingsFrame(stream_id0) client_settings.settings { SettingsFrame.SETTINGS_MAX_CONCURRENT_STREAMS: 100, } tls_sock.sendall(encoder.encode(client_settings)) decoder FrameDecoder() while True: block tls_sock.recv(65535) if not block: break decoder.add_data(block) while True: frame decoder.get_frame() if frame is None: break print(type(frame).__name__, frame.stream_id, frame.flags) # 在这里按帧类型做业务处理代码里的decoder.add_data(block)会把刚 recv 到的字节追加到内部缓冲区get_frame()则尝试解析出一个完整帧。如果数据还不够一个帧get_frame()返回None外层继续 recv。如果缓冲区里已经积压了多个帧内层while会一个一个取出来直到取空。这个模式是 HTTP/2 帧处理的标准套路不管你后面用 h2 状态机还是自己写事件循环都得先过这一关。4. 常见问题与排查技巧实录4.1 粘包半包它是个增量解析器HTTP/2 是二进制协议应用层最常见的坑就是“一次 recv 等于一帧”的错误认知。TCP 不保证消息边界服务端一次 sendall 的数据客户端可能分多次 recv 收到反过来一次 recv 也可能包含对端两次 sendall 的完整帧。我见过不少刚开始写解析器的同学直接把 recv 到的 block 丢给FrameDecoder然后只调用一次get_frame()发现返回 None 或丢帧就以为库有问题。实际上FrameDecoder就是为增量解析设计的。你要做的只有两件事把收到的每个字节全部add_data然后循环调用get_frame()直到返回 None。对于超大帧还要注意FrameDecoder的max_frame_size参数。默认值一般是 16384如果对端根据 SETTINGS 把SETTINGS_MAX_FRAME_SIZE调大到了 65535你解码时也要同步设置FrameDecoder(max_frame_size65535)否则一个合法的大帧会被误判成非法帧。4.2 stream ID 方向、保留位和 stream 0 的边界stream ID 不是随便填的。客户端主动发起的流必须是奇数服务端推送的流必须是偶数。这既是规范要求也是排查问题时最明显的线索。比如你看到一个 stream ID 为 2 的 HEADERS 帧它一定来自服务端 PUSH_PROMISE 创建的流如果客户端发了一个 stream ID 为 2 的 DATA 帧那就直接违反协议。还有一个很隐蔽的点最高位保留位。如果你从抓包工具复制一段帧头然后手动拼 bytes非常容易把保留位算进 stream ID。正确做法是把 4 字节当成无符号整数但最高位必须清零。另外stream ID 为 0 只能用于连接级帧包括 SETTINGS、PING、GOAWAY、WINDOW_UPDATE。DATA、HEADERS、RST_STREAM 这些帧的 stream ID 如果为 0对端一般会直接按 PROTOCOL_ERROR 处理。4.3 PADDED、CONTINUATION 和帧大小限制HTTP/2 帧里还有几个容易踩的细节。第一个是 PADDED flag。DATA、HEADERS、PUSH_PROMISE 这几个帧类型都支持 padding用来混淆流量长度。当 PADDED flag 为 1 时payload 的第一个字节是 pad length表示后面有多少字节是填充内容。解析帧时必须先读这个字段再从末尾跳过相应长度的填充字节否则会把这些填充字节当成真正的业务数据。第二个是 CONTINUATION 帧。HEADERS 帧的 END_HEADERS 标志如果没有设置说明 header block 还没发完后面必须跟着同一个 stream ID 的 CONTINUATION 帧。两者之间不能插入其他流的数据帧这是一条硬性顺序要求。用 hyperframes 解码时你会得到一连续的帧对象需要自己检查 END_HEADERS 标志再决定是否把多个 HEADERS/CONTINUATION 帧的 payload 拼起来交给hpack.Decoder。第三个是帧大小限制。默认帧长度上限是 16384但可以通过 SETTINGS_MAX_FRAME_SIZE 调到 167772152^24-1。超过对端协商值的帧应该被当作 FRAME_SIZE_ERROR。我用FrameDecoder时都会在初始化时设置一个跟对端协商一致的上限并在捕获到异常时检查是不是自己忘了同步 max_frame_size。4.4 常见问题速查表我把实际操作中常遇到的问题整理成了一张速查表排查时可以直接对着看。现象可能原因处理方式get_frame()一直返回 None数据不够一个完整帧或没把数据全部喂进去把所有 recv 数据都 add_data循环 get_frame 到 None解码时抛 InvalidFrameError帧长度超过 max_frame_size检查是否按对端 SETTINGS 调整了 FrameDecoder 参数自己构造的帧被对端 PROTOCOL_ERRORstream ID 方向错误或使用了 stream 0 做业务帧核对帧类型和 stream ID 规则客户端流用奇数HEADERS 解出来是乱码把头块当成了明文没有做 HPACK 解压把 HEADERS 帧和 CONTINUATION 帧的 payload 拼好交给 hpack 解码服务端没收到行为数据连接被关闭DATA 帧没带 END_STREAM或 WINDOW_UPDATE 没按窗口值更新检查流控窗口确认最后一个数据帧带 END_STREAMPING 帧没有回复 ACK忘了处理连接级控制帧收到 PING 后原样返回 opaque_data 并设置 ACK flag这张表里的问题大多数不是 hyperframes 本身的问题而是协议规则没理解透。框架只负责帧的编解码协议边界、状态转换、流控校验都需要你自己完成。5. 从帧到协议栈下一步能怎么扩展5.1 配合 hpack 还原真实请求头HYPERFRAMES 只把 HEADERS 帧的 payload 还原成frame.data这段数据是压缩后的 header block。要拿到真正的 HTTP 头必须交给hpack.Decoderfrom hpack import Decoder as HpackDecoder from hyperframe.frame import HeadersFrame hpack_decoder HpackDecoder() # 假设已经从 FrameDecoder 里取到了 HeadersFrame if isinstance(headers_frame, HeadersFrame): headers hpack_decoder.decode(headers_frame.data) print(headers)hpack.Decoder内部会维护动态表状态同一个连接上的多个 HEADERS 帧必须使用同一个 decoder 实例。如果把每一个 HEADERS 帧都新建一个 decoder动态表索引完全对不上解出来的头一定是乱码。这一步和 hyperframes 的FrameDecoder用法类似都是“状态必须持续保留”的模型。5.2 用 h2 状态机补全连接语义hyperframes 不维护流状态所以它不会告诉你“这个 stream 现在是不是打开状态”“能不能在这个 stream 上发 DATA 帧”。这些属于协议状态机的工作。真正写完整 HTTP/2 客户端或服务端时我会把 hyperframes 放在最底层上面再用h2库的事件模型来管理流生命周期。h2 内部同样依赖 hyperframe所以在调试时你既能拿到高层事件也能看到底层帧对象。这种分层让问题定位变得很快怀疑帧格式错误直接看 hyperframes 的输出怀疑状态机不对再看 h2 事件。5.3 我在实际项目中的选择最后分享一个我实际踩出来的经验。当时我在写一个 HTTP/2 流量录制代理需要在转发的同时保留原始帧字节做回放。有人建议直接高层 h2 API但 h2 的 events 会把帧抽象成语义反而丢掉了我想要的二进制现场。我最后选了 hyperframes 这一层作为录制核心因为它保留的粒度就是帧本身stream ID、flags、原始 payload 全都在我可以把每一帧重新序列化后原样转发也可以在录制完成后逐帧回放。这个定位意味着它不会帮你处理状态但也给了你最大程度的控制权。如果你只是想把一个 HTTP/2 请求发出去直接用httpx或者hyper更省事。可如果你想搞明白协议本身或者想把抓包工具、代理、网关做成自己能完全掌控的样子hyperframes 这层东西值得你花一个下午认真玩一遍。我自己的体会是所有抽象层都会隐藏细节而 HTTP/2 的细节恰恰是调试线上问题时的救命稻草。
返回列表