用h11优雅处理HTTP/1.1:从裸socket到协议状态机的正确姿势
2026/9/9 20:15:46 网站建设 项目流程

干过这行的人应该都有过这种时刻:你想给某个本地 agent 发一个特别简单的 HTTP 请求,懒得引 requests,于是抬手就是一句sock.send(b"GET / 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-Lengthchunked100-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_role=h11.CLIENT) server_conn = h11.Connection(our_role=h11.SERVER)

这个对象内部维护了一个“出站字节缓冲区”和一个“入站解析缓冲区”。而协议数据的流动方式大致是这样:

你的事件 -> conn.send(event) -> conn.traffic() -> 原始字节 -> 网络 网络字节 -> conn.receive_data(data) -> conn.next_event() -> 事件对象

你在客户端写代码时,先把RequestEndOfMessage这些高级事件交给 h11,它负责序列化成GET / HTTP/1.1\r\n...这样的字节;反过来,服务器收到字节后,你调用next_event(),它会还给你一个Request对象或者一段Data。整个过程不涉及任何“我在第几行、第几个字节”这种细节。

2.2 事件模型:半个协议的词汇表

h11 对 HTTP/1.1 的抽象非常克制,主要事件类型就这几个:

事件含义
h11.Request请求首部,包含 method、target、headers
h11.Response响应状态行,包含 status_code、reason、headers
h11.InformationalResponse1xx 临时响应,比如 100 Continue
h11.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 和 RemoteProtocolError

h11 的另一层价值,是它把你和另一端的“协议位置”都记录下来了。它知道你现在是处于“等待请求头”还是“等待办法体结束”的状态,也知道另一端是不是在乱发。正因为如此,它能拦下一堆低级错误。

有一次我写服务器时,忘记先读取客户端请求,就直接调用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_role=h11.CLIENT) sock = socket.create_connection(("example.com", 80)) request = h11.Request( method=b"GET", target=b"/", headers=[ (b"host", b"example.com"), (b"user-agent", b"h11-demo"), (b"accept", b"text/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(b"content-type")

需要说明的是,HTTP 头字段名本质上不区分大小写,但 h11 不会替你强制转成小写。你传进去是b"Content-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_role=h11.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 = b"<html><body><h1>hello from h11</h1></body></html>" headers = [ (b"content-type", b"text/html; charset=utf-8"), (b"content-length", str(len(body)).encode("ascii")), ] conn.send(h11.Response(status_code=200, reason=b"OK", headers=headers)) conn.send(h11.Data(data=body)) conn.send(h11.EndOfMessage()) sock.sendall(conn.traffic()) sock.close()

这个例子有一件事一定要说明白:Content-Length不是 h11 自动帮你算的,是你自己负责。你在构造Response的时候,头部列表是你手写的,h11 不会突然好心地帮你插入Content-Length。如果你漏了它,客户端可能一直等待接收更多数据,因为协议在 HTTP/1.1 里规定了三种 body 结束方式:Content-Lengthchunked、连接关闭。你既不声明长度,也不关闭连接,那客户端就只能傻等。

4.2 服务器不能被“畸形请求”绊倒

真实服务器不能像上面那样裸奔。你至少得捕获h11.RemoteProtocolError。客户端发过来的字节若不符合 HTTP 解析规则,h11 会抛异常。此时你不能什么都不做,至少要回一个400 Bad Request并关闭连接。在我自己的实验代码里,我会写成这样:

try: ... # 上面的解析循环 except h11.RemoteProtocolError: body = b"bad request" conn.send(h11.Response(status_code=400, reason=b"Bad Request", headers=[(b"content-length", b"11")])) conn.send(h11.Data(data=body)) 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_code=100),然后再继续读取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_beginon_urlon_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死磕。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询