ARTICLE DETAIL

资讯详情

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

HTTP/2帧协议解析与hyperframe实战:帧格式、核心API与踩坑指南

HTTP/2帧协议解析与hyperframe实战:帧格式、核心API与踩坑指南 上个月排查一个自研网关的 HTTP/2 上游连接问题抓包抓到深夜最后定位到一个特别隐蔽的错某个中间组件在处理 HEADERS 帧时把帧头里 31 位的 stream ID 按小端读了出来客户端发起的流 3 被服务器当成了流 768整个请求链路瞬间乱套。这个 bug 单看每一行代码都觉得“没毛病”因为绝大多数 HTTP/2 应用层代码根本不会直接碰帧头。也正因为如此很多人对 HTTP/2 的理解常年停留在“比 HTTP/1.1 快”“支持多路复用”这种层面一旦要调试协议细节、写自定义代理、做协议转换就完全没有抓手。hyperframes 这个词在 python-hyper 生态里指的就是 hyperframe 库所处理的那一类数据单元——HTTP/2 帧。hyperframe 是 python-hyper 项目最底层的帧编解码库核心职责只有一件事把 HTTP/2 帧从字节流中解析出来或者把对象重新序列化成字节流。下面把我用 hyperframe 做帧级开发的经验整理出来从帧格式原理讲起到核心 API 用法再到一个能跑的帧嗅探 demo最后汇总我在流 ID、标志位、字节序上踩过的几个坑。适合写网关、代理、协议网关、安全工具的 Python 后端开发者也适合想真正搞懂 HTTP/2 底层机制的读者。1. 为什么 HTTP/2 要造出“帧”这个概念以及帧长什么样1.1 HTTP/1.1 的连接困境与 HTTP/2 的二进制分帧HTTP/1.1 时代请求和响应是纯文本的一个 TCP 连接同一时刻只能处理一个请求顶多靠 pipelining 做点有限度的优化但队头阻塞、响应不能乱序等问题让它几乎没被大规模采用。大家更熟悉的做法是同时开多个 TCP 连接来换并发可连接一多握手开销、慢启动、内存占用全都上来了。HTTP/2 的思路完全换了赛道一个连接上并行跑多个“流”每个流对应一次请求-响应交互。为了让这些流的数据在同一条 TCP 连接上交错传输而互不混淆协议必须定义一种带明确边界和归属信息的最小数据单元——这就是帧。帧可以类比成快递包裹。TCP 连接是那辆卡车帧是车上码放的包裹帧头就是包裹面单上面写清楚这个包裹属于哪个订单stream ID、是什么类型的东西帧类型、体积多大长度、有没有加急等特殊要求标志位。没有面单一车包裹混在一起根本没法分拣HTTP/2 没有了帧多路复用就是一句空话。1.2 帧头的 9 字节结构所有 HTTP/2 帧不管具体类型是什么头部固定 9 字节顺序如下字段长度说明Payload Length24 bit帧体长度不含这 9 字节头部Type8 bit帧类型0~9 为标准类型Flags8 bit类型相关的标志位R1 bit保留位必须为 0Stream ID31 bit归属的流 ID0 表示连接级帧用 Python 解析帧头可以这样写def parse_frame_header(header: bytes): length int.from_bytes(header[0:3], big) frame_type header[3] flags header[4] stream_id int.from_bytes(header[5:9], big) 0x7FFFFFFF return length, frame_type, flags, stream_id注意一点stream ID 最高位是保留位解析时必须用 0x7FFFFFFF 掩掉。别小看这一步网上有些示例代码直接int.from_bytes(header[5:9], big)拿到一个带着高位的大数后续逻辑全被带偏。默认情况下单个帧的负载最大 16384 字节2^14这是 RFC 7540 定的默认值。想发更大的帧可以通过 SETTINGS_MAX_FRAME_SIZE 协商提高到 16777215 字节2^24-1。1.3 十种标准帧类型一览类型值名称主要用途0x0DATA传输请求/响应体数据0x1HEADERS传输 HPACK 编码后的头部块0x2PRIORITY设置流的优先级0x3RST_STREAM终止某个流0x4SETTINGS连接级参数协商0x5PUSH_PROMISE服务端主动推送实际很少用0x6PING心跳与往返时间测量0x7GOAWAY通知连接即将关闭0x8WINDOW_UPDATE流量控制窗口更新0x9CONTINUATION续传未完成的 HEADERS我平时打交道最多的是 SETTINGS、HEADERS、DATA、WINDOW_UPDATE 和 GOAWAY。PUSH_PROMISE 基本可以忽略主流浏览器和服务器默认都禁用了服务端推送。PRIORITY 也很少被真正使用很多实现直接忽略它的语义。1.4 流 ID 的奇偶规则与单调性流 ID 的分配规则是 HTTP/2 帧层最容易出错的地方之一。规范规定客户端发起的流 ID 必须是奇数服务端主动发起的必须是偶数同一方向上新流 ID 必须严格递增已关闭的流 ID 不能复用。连接级帧如 SETTINGS、PING、GOAWAYstream ID 必须为 0。提示写完帧构造代码后先自查三件事——stream ID 是奇数还是偶数、是不是比之前用过的都大、多字节字段是不是全用了大端。这三个问题占了我排查过的帧级 bug 的一半以上。2. hyperframe 库核心用法三十分钟上手构造与解析2.1 安装与基本导入pip install hyperframehyperframe 是纯 Python 实现没有编译依赖装完即可用。导入时最常用的是frame模块下的各类帧对象from hyperframe.frame import ( Frame, DataFrame, HeadersFrame, SettingsFrame, WindowUpdateFrame, PingFrame, GoAwayFrame, RstStreamFrame, PriorityFrame, PushPromiseFrame )这个库的设计哲学很朴素每种帧类型对应一个类类的属性对应协议字段。你构造对象、塞字段、serialize()得到线上字节反过来拿到字节parse 得到对象。核心就是这一来一回。2.2 构造帧从对象到字节先看最常用的 DATA 帧from hyperframe.frame import DataFrame df DataFrame(databhello hyperframes, stream_id1) df.flags.add(END_STREAM) wire_bytes df.serialize() print(wire_bytes.hex())两个关键点。第一DataFrame 构造函数里 data 是帧体stream_id 是归属流。第二flags是一个字符串集合表示协议标志位用集合而不是裸数字就是为了避免手算位运算。END_STREAM对应标志字节第 0 位表示这个流的数据已发送完毕对端可以结束响应了。再看 SETTINGS 帧HTTP/2 握手阶段双方都会发from hyperframe.frame import SettingsFrame sf SettingsFrame(stream_id0) sf.settings { SettingsFrame.MAX_FRAME_SIZE: 65536, SettingsFrame.INITIAL_WINDOW_SIZE: 1048576, SettingsFrame.MAX_CONCURRENT_STREAMS: 128, } bytes_to_send sf.serialize()SETTINGS 帧只能出现在连接级stream_id 为 0帧体里是一组键值对。hyperframe 把这些参数的编号定义成类常量写起来可读性很好也不用去记 ENABLE_PUSH 是 0x2 还是 0x3。收到对端 SETTINGS 后必须回 ACK。确认帧要格外小心stream_id 为 0帧体必须为空只带 ACK 标志ack SettingsFrame(stream_id0) ack.flags.add(ACK) wire ack.serialize()WINDOW_UPDATE 也是手写协议时常用的from hyperframe.frame import WindowUpdateFrame wu WindowUpdateFrame(stream_id0) wu.window_increment 65535 wire wu.serialize()2.3 解析帧从字节到对象解析是构造的逆过程。先取 9 字节帧头调用Frame.parse_frame_header得到对应类型的帧对象再拿帧体调parse_bodyfrom hyperframe.frame import Frame header wire_bytes[:9] frame Frame.parse_frame_header(header) frame.parse_body(wire_bytes[9:]) print(type(frame).__name__) print(frame.stream_id) print(frame.data)parse_frame_header内部先读长度、类型、标志、流 ID再根据类型映射表实例化对应的子类。frame.py里那张 FRAMES 映射表很薄我建议大家拿到这个库先读一遍三分钟就能建立完整的“帧类型 - 类”的对应关系。网络环境下数据往往是分段到达的一个 recv 可能只收到半帧也可能一次收到好几个完整帧。这时候需要缓冲器来“攒字节、切帧”。hyperframe 提供了 FrameBuffer新版本里叫 FrameParsertry: from hyperframe.frame import FrameBuffer except ImportError: from hyperframe.frame import FrameParser as FrameBuffer fb FrameBuffer() fb.add_data(chunk1) # 可能是帧头的一部分 fb.add_data(chunk2) # 补上剩余部分 frames fb.get_frames() # 拿到完整帧列表这个类内部会先凑满 9 字节帧头解析出 body_len再等帧体收齐然后才吐出一个完整帧。写网络接收循环时用它比手动切包稳得多。2.4 标志位的集合设计我早年写协议解析习惯用整数和位运算转用 hyperframe 的字符串集合设计后一开始觉得有点怪用顺了是真省心。每类帧通过defined_flags声明自己支持的标志名解析时自动从字节转成字符串集合序列化时自动算回字节from hyperframe.frame import HeadersFrame hf HeadersFrame(stream_id1) hf.data b\x00\x00\x00\x00\x00 # HPACK 编码后的头部块占位示意 hf.flags.add(END_HEADERS) print(END_HEADERS in hf.flags) # True直接判断字符串不用记 0x1、0x4、0x20 这些魔法数字。如果哪天真要调试原始标志字节自己按位与也算得出来但日常开发几乎用不到。3. 别被名字骗了hyperframe 只管帧HPACK 和流状态是别人的活3.1 python-hyper 生态的分工我刚接触 hyperframe 时有个误解以为有了它就能完整跑 HTTP/2 通信。实际它只是 python-hyper 生态最底层的一块砖。整个生态的分工如下库职责hyperframe帧的构造、序列化、解析字节与对象之间的转换hpackHPACK 头部压缩/解压算法hyper-h2完整 HTTP/2 协议栈含连接状态机、流状态管理、流量控制hyper上层 HTTP/2 客户端面向日常请求-响应编程理解这个分工很重要。很多人在网上搜“Python HTTP/2 库”时先看到 hyper再看到 hyperframe容易误以为后者是前者的简化版。实际上两者解决的问题根本不是一个层级。3.2 明确 hyperframe 三件不会做的事第一不做 HPACK 编解码。HEADERS 帧的 data 字段是 HPACK 编码后的字节hyperframe 原样搬运不解释内容。想自行构造 HEADERS 帧要配合 hpack 库from hpack.hpack import Encoder encoder Encoder() header_block encoder.encode([ (b:method, bGET), (b:path, b/), (b:scheme, bhttps), (b:authority, bexample.com), ]) hf HeadersFrame(stream_id3) hf.data header_block hf.flags.add(END_HEADERS)第二不维护流状态。hyperframe 不知道流 3 现在处于什么状态、下一个新流 ID 该分配多少、这个流能不能继续发 DATA。这些是协议栈的职责。只拿 hyperframe 做收发等于把状态机写在自己手里工作量比想象中大得多。第三不处理流量控制。WINDOW_UPDATE 帧你随时可以构造、可以发但窗口余额怎么算、什么时候必须暂停发送hyperframe 一概不管。这也是生产级项目不要只用 hyperframe 的根本原因——流量控制算错对端会用 RST_STREAM 回应而且这种错误极难排查。3.3 什么时候该裸用 hyperframe既然它不管 HPACK、不管状态机那是不是只能拿来学习当然不是。我实际用 hyperframe 主要是三类场景。一是协议调试工具。抓包后把 SETTINGS、WINDOW_UPDATE、GOAWAY 这类控制帧解析成可读信息比盯着十六进制数高效得多。二是自定义网关或代理的帧转发层。你不需要跑完整 HTTP/2 语义只想把帧从客户端搬到后端或者按流 ID 做一次过滤这时用 hyperframe 做帧编解码刚刚好省去一整套状态机的开销。三是安全研究和协议测试。构造畸形帧、试探对端对超长帧或异常标志位的反应这种场景需要逐字节控制hyperframe 提供了从对象到字节的完整通路。4. 实战项目用 hyperframe 写一个 HTTP/2 帧嗅探与转发基座4.1 最小服务端接收 preface 和 SETTINGSHTTP/2 明文连接h2c要求客户端先发送 24 字节的连接前言PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n随后立刻发一个 SETTINGS 帧。服务端则应回自己的 SETTINGS并对客户端的 SETTINGS 回 ACK。下面这个脚本用 socket hyperframe 实现一个能打印所有入站帧的最小服务端import socket try: from hyperframe.frame import FrameBuffer except ImportError: from hyperframe.frame import FrameParser as FrameBuffer from hyperframe.frame import SettingsFrame H2_PREFACE bPRI * HTTP/2.0\r\n\r\nSM\r\n\r\n def print_frame(frame): flags ,.join(sorted(frame.flags)) if frame.flags else - print( ftype{type(frame).__name__:20s} fstream_id{frame.stream_id:5d} flags{flags} ) def handle(conn): # 第一步读 preface。本地演示假定一次 recv 能收满 24 字节 # 严谨做法是用一个小循环凑满 preface 长度再往下走。 data conn.recv(len(H2_PREFACE)) if data ! H2_PREFACE: print(not h2 preface, close) return fb FrameBuffer() sent_settings False while True: chunk conn.recv(4096) if not chunk: break fb.add_data(chunk) for frame in fb.get_frames(): print_frame(frame) if isinstance(frame, SettingsFrame) and ACK not in frame.flags: if not sent_settings: my_settings SettingsFrame(stream_id0) my_settings.settings { SettingsFrame.MAX_FRAME_SIZE: 65536, SettingsFrame.INITIAL_WINDOW_SIZE: 1048576, } conn.sendall(my_settings.serialize()) sent_settings True ack SettingsFrame(stream_id0) ack.flags.add(ACK) conn.sendall(ack.serialize()) server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 8080)) server.listen(1) print(listening on 127.0.0.1:8080) conn, _ server.accept() handle(conn)这段代码的重点是 FrameBuffer 的循环用法不管底层把帧拆成几段接收循环里只负责add_data和get_frames切帧的事全部交给库处理。握手回包这里只处理了两件事回自己的 SETTINGS回 ACK。这两步是 HTTP/2 握手阶段服务端必须做的少了任何一步客户端都会卡在等 SETTINGS 的阶段。4.2 用 curl 和 nghttp 验证脚本跑起来后用 curl 的 h2c 模式连一下curl --http2-prior-knowledge http://127.0.0.1:8080/ -vcurl 会先发 preface再发 SETTINGS然后发 HEADERS内含 HPACK 编码的请求头。服务端脚本应该打印出类似这样的输出typeSettingsFrame stream_id0 flags- typeHeadersFrame stream_id1 flagsEND_HEADERS如果装了 nghttp 客户端表现力更强nghttp -nv http://127.0.0.1:8080/nghttp 会打印更完整的帧序列包括 WINDOW_UPDATE。你可以把 nghttp 的描述和脚本打印的结果做对比两边一致说明解析逻辑没跑偏。这一步是检验自己是否真的理解帧层的最好方式——不是看书看会的是对照着两个独立实现验证过的。4.3 扩展按流 ID 做帧过滤的转发基座嗅探只是热身。顺着这个思路可以很自然地扩展成一个半透明代理客户端连到代理代理再连后端两边各维护一个 FrameBuffer收到的帧按 stream_id 决定是转发、丢弃还是改写。下面是一个简化的转发逻辑骨架def pump(src, dst, fb, allowed_streamsNone): chunk src.recv(4096) if not chunk: return False fb.add_data(chunk) for frame in fb.get_frames(): if allowed_streams and frame.stream_id not in allowed_streams: print(fdrop stream {frame.stream_id} frame {type(frame).__name__}) continue dst.sendall(frame.serialize()) return True注意这里有个坑转发时必须用frame.serialize()重新序列化而不是直接转发原始字节。因为 FrameBuffer 可能把一个帧跨多次 recv 拼出来原始切片已经不在手上了。重新序列化还能顺带统一字节序、过滤掉不想保留的标志位。我最早写转发代理时图省事直接dst.sendall(chunk)结果帧被重组后带着残留的 padding 字节对端解析全部错位排查了很久才发现是这一段的问题。5. 踩坑汇总帧长度、流 ID、标志位和字节序那点事5.1 帧长度上限16384 不是可以随便突破的HTTP/2 默认单个帧的最大长度是 16384 字节。如果你的 DATA 帧超过这个值对端会直接报 FRAME_SIZE_ERROR 并断开连接。要发大数据正确做法是协商 SETTINGS_MAX_FRAME_SIZE最大能提到 16777215 字节或者把数据切成多个帧。有朋友问过我如果我不协商直接把 100KB 的帧发给 nginx 会怎样结果就是连接被重置。这不是 hyperframe 的限制是协议本身的限制。构造帧时可以在serialize()前检查len(frame.data)或者写好正确的 SETTINGS 协商逻辑。还有一个容易忽略的点帧头里长度字段只有 24 位所以理论最大值就是 16777215就算 SETTINGS 里写了更大的数对端也会拒绝。5.2 stream ID 的奇偶与递增写客户端和服务端都不一样再说一次客户端发流用奇数 1、3、5……服务端主动发流用偶数 2、4、6……。更要命的是新流 ID 必须比之前用过的都大申请新流不能复用已经关掉的流 ID。我在一个代理项目里踩过这个坑转发时给每个上游请求重新分配了 stream ID但因为复用了池子里的旧 ID对端直接 GOAWAY。排查半天才发现是流 ID 冲突。所以如果你在写代理或转发强烈建议维护一个单调递增的分配器每分配一个就记录到已用列表里连接结束清空重来。5.3 标志位加错地方报文直接废不同帧类型支持的标志位不一样。DATA 帧只有 END_STREAM0x1和 PADDED0x8HEADERS 帧有 END_STREAM、END_HEADERS0x4、PADDED0x8、PRIORITY0x20。如果你在 DATA 帧上添加 END_HEADERShyperframe 本身不报错——它只是不知道这个帧类认识这个标志但 serialize 出来的标志字节是按位实际算的对端解析时自然一塌糊涂。更隐蔽的是 HEADERS CONTINUATION 的组合当一个 HEADERS 帧的 HPACK 块太长放不下时要用 CONTINUATION 帧续传而且只有最后一个帧才能带 END_HEADERS。这种“跨帧”的头部块完整性hyperframe 不帮你校验得自己在更上层管。初次写 HTTP/2 栈的人十个有八个在这里出错。注意如果只是做帧转发尽量不要改动标志位。HEADERS 和 CONTINUATION 的 END_HEADERS 分布一旦被改错完整性的责任就落到了你头上而对端只会给你一个冷冷的连接错误。5.4 字节序大端就是大端别想当然HTTP/2 帧头的多数字段都是大端网络字节序。长度字段int.from_bytes(header[0:3], big)流 ID 也是 big。用 little 解析出来的数会完全不对。这种 bug 的典型表现是单帧解析看似正常多帧一连起来就乱套因为你把帧长度解析错了后续切帧全错位。我在排查一个跨语言组件时遇到过对方把长度字段按小端读读出来的值巨大导致后续所有帧解析全部错位连接永远握手失败。所以写帧解析代码时第一时间就要确认所有多字节字段全部用了大端。5.5 用 Wireshark 做交叉验证写完自己的解析逻辑别急着信任用 Wireshark 抓包交叉验证是最省事的办法。Wireshark 对 HTTP/2 的支持很完善能看到每一帧的类型、长度、标志、流 ID甚至会标出 HPACK 解码后的头部内容。我的验证习惯是先让程序跑起来再用 tshark 导出帧汇总tshark -r capture.pcap -Y http2 -T fields -e http2.type -e http2.stream_id -e http2.flags然后和程序打印的帧序列逐行对比。两边对不上马上就能定位到是解析还是序列化的哪一步出了问题。这套方法在排查代理类项目时救过我很多次比单靠看代码猜高效得多。5.6 汇总五个最常见的帧级问题坑典型现象根因对策帧长超限RST_STREAM / 连接重置超出默认 16384 上限协商 MAX_FRAME_SIZE 或分帧流 ID 复用对端 GOAWAY违反单调递增维护单调递增分配器标志位加错语义错乱 / 解析异常标志与帧类不匹配对照 defined_flags字节序错误长度或流 ID 巨大小端读大端统一用 bigSETTINGS ACK 带 bodyFRAME_SIZE_ERROR违反 RFCACK 帧体必须为空最后补一个容易忽略的细节SETTINGS ACK 帧必须 stream_id 为 0且帧体长度必须为 0否则对端返回 FRAME_SIZE_ERROR。我第一次写握手逻辑时习惯性往 ACK 里塞了 settings 内容结果对方直接断连。后来翻 RFC 7540 才知道ACK 就是“我收到了你的设置”这个信号的纯标记不允许携带任何参数。最后分享一个我这半年养成的习惯凡是需要调试 HTTP/2 的地方我第一件事就是跑一个 30 行的帧打印脚本把 SETTINGS、WINDOW_UPDATE、GOAWAY 这些控制帧的可读信息先拉出来。很多人一上来就盯着 HPACK 解码、状态机流转其实一半的握手问题都出在更底下一层——帧本身的构造和解析。把这一层摸透了再看 hyper-h2 的源码、再调代理的流管理心里会踏实很多。
返回列表