
干过这行的人应该都有过这种时刻你想给某个本地 agent 发一个特别简单的 HTTP 请求懒得引 requests于是抬手就是一句sock.send(bGET / HTTP/1.1\r\nHost: x\r\n\r\n)然后recv回来用split(b\r\n\r\n, 1)拆一下。第一次跑得很顺第二次开始出现灵异现象body 比预想中短一截下一次又多了几个看不懂的字节。问题不在你拼请求拼错了而在你低估了 HTTP/1.1 作为“协议”的那一层。这个场景就是 h11 存在的意义。h11 是一个纯 Python 实现的 HTTP/1.1 协议状态机它不管 socket、不管并发、不管 TLS只干一件事把字节流和 HTTP 语义事件互相转换。今天这篇“一天一个 Python 库”我就拿它聊聊为什么要重视协议解析层以及怎么用这个库把自己的客户端、服务器写得既干净又能放心复用连接。适合那些已经会用socket、但不想再自己实现一遍Content-Length、chunked、100-continue这套东西的开发者。1. 我在字节流上翻过车HTTP/1.1 并没有那么好啃1.1 手写解析的第一次成功会给你一种“不过如此”的错觉很多 Python 开发者第一次手写 HTTP 报文解析都是从“拿到响应头”开始的。对拆状态行、拆 Header、找空行确实不难。一台服务器如果只返回一个固定长度的Content-Length不启用 keep-alive每次回完数据就Connection: close那你手写解析是能跑的。可现实里的服务不是这么干的。我踩过一个典型的坑内网监控 agent 通过 Unix domain socket 接收请求响应使用 keep-alive。我第一次收到完整响应正好对上了Content-Length一切正常。第二次带上了之前的连接残留字节我天真地往后多读了一段结果把下一条响应的开头发当成了 body 的尾巴。从那一刻开始我意识到一个关键问题HTTP 报文真正的难点不在“拆出 Header”而在回答另一个更基础的问题——body 到底在哪里结束。1.2 body 边界问题是协议语义问题不是字符串处理问题你说 body 在哪里结束最老实的情况是Content-Length告诉你长度。但 HTTP/1.1 里还有Transfer-Encoding: chunked还有根本没有 body 的 HEAD 响应还有状态码 204/304 这种“响应不允许有 body”的语义还有Connection: close表示“读到连接关闭为止”还有Expect: 100-continue把请求流程拆成两段。这些都不是“按\r\n\r\n切一刀”能解决的事它们需要状态机、需要记录当前解析到哪个阶段、需要知道下一个合法输入是什么。我刚做那个脚本的时候第一版代码如下data sock.recv(65535) head, _, body data.partition(b\r\n\r\n)当时只觉得“够用”。后来遇到 chunked 响应我甚至写了半天的while True去读十六进制块大小。那会儿再回头看发现我已经在重复造一个很容易出安全问题的轮子。你处理边界条件只要少读一个 CRLF整个 body 就错位多读一个字节下一条请求又会串包。1.3 h11 的定位把协议层从网络层里单独拎出来h11 看待问题的角度非常冷静它就是要做一个“纯 Python 的 HTTP/1.1 协议实现”并且只做这一层。它给你两个基础设施一个是把原始字节喂进去的输入口一个是把抽象事件吐出来的输出口反过来你也可以把高级事件塞进去让它变成你要发出去的原始字节。至于这些字节怎么通过网络发出去、什么时候发h11 一概不管。这种设计的好处是你可以把它插进任意 I/O 模型里同步阻塞 socket、非阻塞 selectors、asyncio、trio、多线程服务器都行。因为它内部没有recv没有send没有 event loop它只会基于你给它的字节做协议判断。这也是我后来愿意认真读它源码的原因——一个不掺 I/O 的协议状态机读起来清爽太多。2. h11 的三件法宝事件、缓冲区、连接状态机2.1 双向数据流与 Conn 对象的角色h11 最核心的类是h11.Connection。无论你是客户端还是服务器都要创建这样一个连接对象并告诉它你的角色client_conn h11.Connection(our_roleh11.CLIENT) server_conn h11.Connection(our_roleh11.SERVER)这个对象内部维护了一个“出站字节缓冲区”和一个“入站解析缓冲区”。而协议数据的流动方式大致是这样你的事件 - conn.send(event) - conn.traffic() - 原始字节 - 网络 网络字节 - conn.receive_data(data) - conn.next_event() - 事件对象你在客户端写代码时先把Request、EndOfMessage这些高级事件交给 h11它负责序列化成GET / HTTP/1.1\r\n...这样的字节反过来服务器收到字节后你调用next_event()它会还给你一个Request对象或者一段Data。整个过程不涉及任何“我在第几行、第几个字节”这种细节。2.2 事件模型半个协议的词汇表h11 对 HTTP/1.1 的抽象非常克制主要事件类型就这几个事件含义h11.Request请求首部包含 method、target、headersh11.Response响应状态行包含 status_code、reason、headersh11.InformationalResponse1xx 临时响应比如 100 Continueh11.Databody 的一段原始数据h11.EndOfMessage消息体结束可能附带 trailer 字段h11.ConnectionClosed对端已关闭连接数据流结束h11.NEED_DATA/h11.PAUSED状态信号不是普通事件注意最后一行NEED_DATA不是“事件”它是next_event()的一种返回值。每次你从 socket 接收到字节扔给conn.receive_data(data)然后循环调用next_event()。如果它返回NEED_DATA说明当前缓冲区里的字节还不足以拼成一个完整的协议事件你需要去读更多网络数据如果返回PAUSED说明协议状态机当前不允许你再继续读典型场景是你还没处理完上一条消息的 body。2.3 状态机如何保护你LocalProtocolError 和 RemoteProtocolErrorh11 的另一层价值是它把你和另一端的“协议位置”都记录下来了。它知道你现在是处于“等待请求头”还是“等待办法体结束”的状态也知道另一端是不是在乱发。正因为如此它能拦下一堆低级错误。有一次我写服务器时忘记先读取客户端请求就直接调用conn.send(h11.Response(...))结果 h11 立刻抛了LocalProtocolError。报错信息大意是在当前状态下你还没收到请求不能先发响应。这个报错非常及时。如果你用裸 socket 写服务器很容易手滑把逻辑顺序搞反最后调试半天才发现是自己把请求和响应顺序写错了。反过来如果对端发来一个畸形报文比如 header 里出现了无效字符、Content-Length重复且相互冲突h11 会在解析时抛出RemoteProtocolError。这一类异常你必须在服务器主循环里捕获否则一个恶意请求就能让你整个进程挂掉。3. 从裸 TCP 出发写一个能拿到真实响应的客户端3.1 构造一个最小请求Request EndOfMessage我不会直接上框架咱们就从系统自带的socket开始。这样你才能更直观地看到 h11 在中间做了什么事。先看客户端的完整请求发送部分import socket import h11 conn h11.Connection(our_roleh11.CLIENT) sock socket.create_connection((example.com, 80)) request h11.Request( methodbGET, targetb/, headers[ (bhost, bexample.com), (buser-agent, bh11-demo), (baccept, btext/html), ], ) conn.send(request) conn.send(h11.EndOfMessage()) data_to_send conn.traffic() sock.sendall(data_to_send)这里有一个很容易第一次就用错的地方conn.send()并不会真的把数据发进 socket。它只是把事件转换成协议字节放进 h11 内部缓冲区。真正的“把字节拿出来”这个动作要调用conn.traffic()。很多从 requests 转过来的朋友一开始忽略这一步结果发现自己明明调用了send网络上却什么包都没发出去。EndOfMessage这个事件对应到原始报文里就是那个空行它表示“请求头部结束且请求体为空”。如果你要发 POST请求体放在Data里然后再发一个EndOfMessage()作为 body 的结束。3.2 用 next_event 循环读取响应请求发出去之后接下来是一个典型的读取循环body b status_code None while True: event conn.next_event() if event is h11.NEED_DATA: data sock.recv(4096) if not data: conn.receive_data(b) else: conn.receive_data(data) elif isinstance(event, h11.Response): status_code event.status_code print(status:, status_code) print(headers:, event.headers) elif isinstance(event, h11.Data): body event.data elif event is h11.EndOfMessage: break elif event is h11.ConnectionClosed: break这个循环的模式值得多说几句。h11.NEED_DATA表示解析器当前没有足够字节生成下一个事件于是我们去 socketrecv。拿到数据后交给conn.receive_data()然后回到循环再试一次。整个关系是“拉取”式的不是解析器回调你而是你主动向它要下一个事件。这让代码的逻辑极其好懂。当对端把连接关闭socket.recv()返回空字节串。这时你调用conn.receive_data(b)h11 就会在下一次next_event()时返回ConnectionClosed。注意ConnectionClosed是事件不是异常所以不能靠except来捕获它。这是 h11 API 一个容易让人不适的地方但适应了会觉得清晰所有“连接层面发生了什么”都被统一表达为事件。3.3 事件里的 headers 为什么不是普通 dict很多人在这一步会下意识把event.headers转成字典。我劝你冷静。HTTP 头字段是可以重复的Set-Cookie一次性返回多个值是正常现象即使不是Set-Cookie同一个头字段名也可能合法出现多次。h11 的Headers对象保留了顺序和重复信息它支持按名字查询某个值也支持像列表一样遍历。你要是图省事转成dict信息就丢了。在我自己的小工具里我会用它做这类处理headers h11.Headers(event.headers) content_type headers.get(bcontent-type)需要说明的是HTTP 头字段名本质上不区分大小写但 h11 不会替你强制转成小写。你传进去是bContent-Type它原样保留比较的时候最好自己做casefold或者统一用小写字节。这不是 h11 的缺陷而是协议层遵循“保留原始表现”的原则。3.4 复用连接的关键start_next_cycle 与 Connection 头如果你只想发一个请求然后关闭 socket那上面代码就够了。但 HTTP/1.1 的默认行为是 keep-alive很多服务器会继续保有连接等你下一次请求。如果你想在同一条 TCP 连接上发第二个请求必须在读完第一个响应的EndOfMessage之后显式告诉 h11“这条消息已经处理完了请重置状态机”。对应的方法是conn.start_next_cycle()忘记这步是高频错误。我第一次跑长连接的时候第一个请求正常第二个请求在conn.send()阶段直接抛出LocalProtocolError。原因就是状态机还停留在DONE状态没有回到IDLE它认为你还没有结束当前消息就开始新消息这在协议上是不合法的。另外别忘了看响应头里的Connection字段。如果对端明确说了Connection: close那就不要再复用连接了老老实实读完 body 后关 socket。你说你偏要复用在协议上这是一种“违规”h11 不会替你去判断这一点但服务器很可能在下一次响应后把你断开届时表现会更加诡异。4. 做一次最小但严格的服务器从“收到请求”到“干净关闭”4.1 服务器角色先等请求再回响应客户端写完我们再来看服务端。h11 服务端的核心循环跟客户端的读取循环很像但角色变成了h11.SERVER。下面这是一个极简单连接服务器它只服务一个请求完成后关闭连接import socket import h11 def handle_connection(sock): conn h11.Connection(our_roleh11.SERVER) received_body b request None while True: event conn.next_event() if event is h11.NEED_DATA: data sock.recv(4096) conn.receive_data(data if data else b) elif isinstance(event, h11.Request): request event elif isinstance(event, h11.Data): received_body event.data elif event is h11.EndOfMessage: break elif event is h11.ConnectionClosed: return # 构造响应 body bhtmlbodyh1hello from h11/h1/body/html headers [ (bcontent-type, btext/html; charsetutf-8), (bcontent-length, str(len(body)).encode(ascii)), ] conn.send(h11.Response(status_code200, reasonbOK, headersheaders)) conn.send(h11.Data(databody)) conn.send(h11.EndOfMessage()) sock.sendall(conn.traffic()) sock.close()这个例子有一件事一定要说明白Content-Length不是 h11 自动帮你算的是你自己负责。你在构造Response的时候头部列表是你手写的h11 不会突然好心地帮你插入Content-Length。如果你漏了它客户端可能一直等待接收更多数据因为协议在 HTTP/1.1 里规定了三种 body 结束方式Content-Length、chunked、连接关闭。你既不声明长度也不关闭连接那客户端就只能傻等。4.2 服务器不能被“畸形请求”绊倒真实服务器不能像上面那样裸奔。你至少得捕获h11.RemoteProtocolError。客户端发过来的字节若不符合 HTTP 解析规则h11 会抛异常。此时你不能什么都不做至少要回一个400 Bad Request并关闭连接。在我自己的实验代码里我会写成这样try: ... # 上面的解析循环 except h11.RemoteProtocolError: body bbad request conn.send(h11.Response(status_code400, reasonbBad Request, headers[(bcontent-length, b11)])) conn.send(h11.Data(databody)) conn.send(h11.EndOfMessage()) sock.sendall(conn.traffic()) finally: sock.close()有一个细节你可能没意识到服务器在收到异常请求后如果还想“礼貌地”回一个 400它必须处于能安全发送响应的状态。h11 的状态机会帮你保证这一点如果当前协议状态已经乱到无法发送你会得到一个异常而不是发出去一个语义错乱的响应。这比裸 socket 世界里“反正我硬写回包”的做法安全得多。4.3 多请求与 keep-alive 的取舍上面例子是处理完一个请求就close这样最简单也最不容易出错。但在真实场景里一个连接上可能会连续来好几个请求。你可以在处理完EndOfMessage后不关闭 socket而是调用conn.start_next_cycle()然后继续while True再等下一个Request事件。不过这种 keep-alive 服务器的复杂度会明显上升。你得考虑一个问题如果客户端发完一个请求后既不发送下一条请求也不关闭连接你该怎么办大多数简单服务器会在这里被一个“半开着但没数据”的连接卡很久。所以生产环境里要么给 socket 设置超时要么根本不做 keep-alive。拿 h11 做实验服务器时这段取舍是不可避免的你得自己在协议正确性和资源可控性之间找平衡。5. 用 h11 时我踩过的坑分块、100-continue、状态机报错5.1 chunked 响应h11 替你解好了块这是大加分项我最早手写解析器时最头疼的就是Transfer-Encoding: chunked。这种编码不是简单给一个Content-Length就完事而是分块发送每块前有一个十六进制长度以\r\n分隔最后还要一个0\r\n\r\n表示结束。你如果自己读 socket必须维护一个“读到块长度、读块、再读块长度”的小状态机。很多刚入门的实现会在这里出 bug。用 h11 就不一样了。它对 chunked 的处理是透明的你从next_event()拿到的h11.Data事件其data字段已经是解码后的实际内容了。你不需要关心块长度、不需要关心块之间的 CRLF、也不需要关心块结束标记。协议的原始字节在 h11 内部被消化干净暴露给你的只有“干净的 body 片段”。这一点对写代理、写调试工具的体验提升是巨大的。我记得第一次用 h11 跑一个返回 chunked 的接口时我惊讶地看到Data事件接二连三地到来每个事件的 data 长度都小于最大块大小但拼起来正好是完整 JSON。我当时还在想自己曾经为这个写过十来行的解析器现在一个事件模型全搞定了。5.2 100-continue 不给足对端会一直等你Expect: 100-continue这个机制挺冷门但在和某些 HTTP 客户端联调时你一定会遇到。它的协议语义是客户端打算发送一个比较大的请求体但不急着发它先问服务器“你要不要收”服务器想收就先回一个100 Continue的临时响应客户端收到后再正式发送请求体。如果你用裸 socket 手写服务器这里非常容易忽略这个握手。结果就是客户端在等你的100 Continue你却在对端等着它发 body两边死锁。用 h11 时它会把这个情况反映在状态机上当你读到请求头并且头部里有expect: 100-continue时你可以选择先发送h11.InformationalResponse(status_code100)然后再继续读取Data事件。这个临时响应不会终结当前请求它只是告诉客户端“我准备好了你继续发”。你甚至可以反过来利用这一点如果你想拒绝客户端请求体直接回417 Expectation Failed然后关连接客户端就不该再发 body 了。但在 h11 里这种操作的协议约束比较严格我的建议是多数情况下按“先回 100再正常接收 body”这个流程走不容易触雷。5.3 别忽略 EndOfMessage 里的 trailer 字段HTTP 里的 trailer 很少见但它合法存在。它出现在 chunked 编码的末尾本质上是在 body 全部发完之后再跟上一组 header 字段用于传递只在发送时才确定的元信息。h11 对它的处理是EndOfMessage事件里可能带有headers字段。很多第一次用 h11 的人看到EndOfMessage就 break 了完全没检查它有没有携带 trailer。绝大多数情况这没关系但如果你在写代理需要把响应原样转发给下游丢掉 trailer 就是一种语义损伤。正确的习惯是始终把EndOfMessage当作“一个可能带 payload 的事件”而不是“一个朴素结束标记”。5.4 两处会让新手怀疑人生的异常第一处是LocalProtocolError。它发生在你自己代码写错协议顺序的时候。典型场景包括服务器没收到请求就发响应、客户端在请求未结束前就试图读取响应、连接复用前没有重置状态机。这个异常的核心信息是“是你的错不是对端的错”。第二处是RemoteProtocolError。它是对端发来非法字节导致的。比如字段解析失败、chunked 块长度非法、Content-Length和实际 body 不一致到无法判定边界。服务器遇到这个异常最稳妥的做法就是像前面说的回 400 后关闭连接。千万别尝试继续复用这条连接此时内部缓冲区已经不可信了。我还想特别提醒一个 buffer 相关的问题。h11 会无脑积累你receive_data馈给它的字节直到它能拼出足够的事件。如果你写了这样的代码while True: data sock.recv(65535) conn.receive_data(data) event conn.next_event()如果不加NEED_DATA判断你会发现next_event()通常只解析出一个事件而receive_data却塞了一大堆字节进去。对于恶意请求这可能变成一个内存放大器。我写公共对外服务时都会在外层额外限制未解析缓冲区的总大小因为 h11 本身不会替你设这个上限。6. 别用 h11 写“请求库”用它写只有你知道的协议层6.1 不是所有 HTTP 任务都适合 h11有一个判断标准很简单如果你的最终目标是“发一个 GET 请求拿到 JSON”那就别绕弯子用 requests 或 httpx。h11 没有重定向自动处理、没有 cookie 自动管理、没有 TLS 连接池、没有超时重试这些统统不是它的目标。它是给“想控制协议层”的人用的不是给“想赶紧把接口调通”的人用的。我自己用它最多的地方是那些 requests 反而显得碍事的场景。比如我要往一个本地服务发送自定义 HTTP 报文或者要构造一个带恶意头字段的请求给测试服务器打过去或者需要在测试脚本里模拟一个慢速、分块响应看看下游客户端的超时逻辑是不是靠谱。在这些场景里requests 的抽象层次太高反而会“好心”地帮你做了很多你不想要的事而 h11 正好是我要的那个“听话的中间层”。6.2 和 http.client、httptools 的区隔在哪标准库里有http.client也能处理 HTTP/1.1但它和 socket 之间的耦合比 h11 深设计上更像一个“低级客户端”而不是一个对称的协议状态机。你很难拿http.client干净地写一个自定义服务器。反观 h11请求方向和响应方向是对称的你把角色设为SERVER就能独立实现服务端协议逻辑。还有一类解析器叫httptools它用 Cython 实现速度很快但接口是回调式的需要你注册on_message_begin、on_url、on_headers这类函数写起来比较“事件驱动”。h11 是拉取式的适合那些更喜欢同步直觉、希望代码从顶到底自然阅读的人。两者没有绝对好坏但如果你在意纯 Python 带来的可调试性和跨平台安装便利h11 更合适。6.3 一个更真实的组合姿势h11 你自己的事件循环如果你确实要构建一个稍微认真点的 HTTP 工具我建议保留 h11 的纯协议层在外面自己包一层“连接管理器”。比如可以做一个很小的 selectors 服务器每个连接一个h11.Connection对象维护一个{socket: h11.Connection}映射。收到可读事件就先conn.receive_data()然后持续调用conn.next_event()批处理当前缓冲区内所有完整消息。写缓冲时则把conn.traffic()的输出暂存。这个模式是我自己实践后觉得最顺手的既离字节足够近又不会被协议细节淹没。我还常用它做契约测试。比如让测试里的“假服务端”直接用 h11 吐出一段精心构造的响应来观察被测客户端在异常头部、超长 body、或 chunked 中途断开时的行为。没有 h11 之前我都是手拼字节拼错一个\r\n就很难排查有 h11 之后测试代码的意图至少清楚了十倍。最后分享一个实践经验如果你要在公网上写一个真正面向陌生流量的 HTTP 服务别只靠 h11 而裸手写完整服务器。协议解析只是服务器的一小块还有超时、并发、TLS、限流、日志等一堆问题。h11 适合做协议层的地基不适合当你唯一的防线。我更推荐把它用在你熟悉流量来源的内部服务、调试工具、代理实验和学习项目上。在这些地方h11 干净的状态机和事件模型能让你省下大量时间去解决真正有趣的问题而不是和\r\n\r\n死磕。