解析来自 TCP 流的 HTTP 响应

Parse HTTP responses from a TCP stream

TCP 不是基于消息的协议,但它是一个简单的字节流。实际上,HTTP 协议是基于 TCP 的消息协议。那么,如何从 TCP 流连接解析原始 HTTP 数据呢?

例如,我们通过 python 中的 TCP 套接字连接到代理服务器:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))  # host and port are proxy's address

然后,我们询问代理,是否可以通过它CONNECT到目标主机(google.com,例如):

request = b'CONNECT %s:%i HTTP/1.0\r\n\r\n' % ("google.com".encode(), 443) 
s.sendall(request)

然后,我们需要从socket接收数据。 但是如何?如果我们 recv 数据,我们将其保存到缓冲区中,如下所示:

buffer = s.recv(1024)

我已经检查过,当主机关闭连接时,它会发送一条 0 字节长的消息(例如 404502400 状态代码)。但是当连接处于活动状态时(主机返回状态代码 200),它 不会发送终止 0 字节 。当然,它不应该,但是,我们怎么知道消息到此结束?

我对 HTTP 协议所做的是 headers 除以 \r\n 并且 body 从 headers 中除以 \r\n\r\n。 HTTP 消息总是以 \r\n 结尾。因此,从理论上讲,我们可以只阅读消息直到遇到 \r\n\r\n,然后我们知道消息的其余部分,直到另一个 \r\n,是响应的 body。

但是,如果一些 joker 服务器想要在 中放置另一个 \r\n http 响应 body 怎么办?然后整个解析就坏了! 现在算法认为 body 已经结束,消息的其余部分是下一条消息的 header 并抛出异常,试图解析它! 如果一些有趣的人写了一个服务器,将 \r\n\r\n 放在自定义响应中 header?

那么我们如何从原始套接字进行解析,它是如何正确完成的?我们如何才能不在一些错误配置的服务器响应上出错?

使用 TCP 套接字时,您实际上并不知道何时收到了整个消息。我过去做过的一种方法是使用固定长度的消息并发送一条初始消息,消息传输的大小以字节为单位。然后我可以接收消息的字节,直到收到预期的字节总数。我认为最佳答案来自 Python socket receive - incoming packets always have a different size

有帮助。

这不是对 HTTP 的非常精确的描述。尽管该协议肯定有其缺陷,但它比您的摘要所表明的更强大,成功传输的大量数据证明了这一点。

当然,传输成功需要服务器正确实现协议。服务器错误将导致无法正确接收消息。例如,如果服务器要在 header 中发送一个额外的 CR-LF,客户端会认为接下来是消息 body,这可能会导致某种故障.但是,消息的 body 不是那么敏感。任意字节流,包括任意行结尾甚至 NUL 字节都可以通过 HTTP 传输。

有三种机制用于打包 body。在最初的 HTTP 规范中,body 简单地扩展到服务器关闭 TCP 连接的程度,因此单个 TCP 连接只能服务于单个 HTTP 响应。

顺便说一句,服务器在关闭连接之前不会发送 zero-length 消息。没有办法做到这一点,因为正如您所指出的,TCP 只是一个字节流。它根本不是 message-based 协议;所以不可能发送任何长度的消息,包括零。

来自 read() 的 zero-length return 由接收方的标准库制作,以便与 read() 的调用者通信,不再有数据;换句话说,连接已被另一端关闭。这与文件中的 read() 表示已到达文件末尾的方式相同。当您从一个文件 read() 收到零字节时,那不是因为文件中有圆顶“zero-length 数据包”。与 TCP 流一样,文件只是没有消息标记的未区分字节序列。

但要回到 HTTP。由于没有什么可以阻止客户端打开任意数量的到单个服务器的连接,因此最初的“一个连接,一个请求”通信协议是可行的。但是打开和断开 TCP 连接的开销相当大,而且许多 HTTP 消息非常短。所以它的可扩展性不是很好,下一个 HTTP 版本必须包含一种机制,可以通过单个 TCP 连接发送多条消息(称为“流水线”)。

但是,拥有大量处于休眠状态的打开的 TCP 连接也会给服务器带来不必要的开销。所以仍然允许随时关闭一个连接;如果客户端随后想要发出新请求,它必须打开一个新连接。

客户端请求和服务器响应均由 header 组成,可能后跟 body。 body 的存在取决于 header 的内容,流水线传输的正确运行需要服务器和客户端就特定消息 header 是否会在 body 与否。意外的 body 将在另一侧被解释为新的 header,可能是 ill-formed.

发件人可以通过两种方式来描述邮件 body 的范围。最直接的方法是简单地包含一个 header,其中包含 body 的精确字节长度。 (A "Content-Length" header.) 在 header 完成后(由两个连续的 CRLF 序列指示),内容长度 header 指示的下一个字节数是视为 body,无需查看字节。 (如果服务器注入了不计入声明内容长度的额外字节,那将导致另一端解析错误,如果它遗漏字节也是如此。但是消息包含任意数量的连续 CRLF 都没有问题。 )

一旦 body 已完全发送,发送方可以通过发送 CRLF 来指示另一条消息,或者它可以关闭其连接端。如果另一端的主机厌倦了等待,它也可以关闭连接。

如果发件人知道内容的长度(例如,如果内容是一个文件),那么发送 Content-Length header 很容易,但是消息体通常是动态生成的,并且它们是完整的在生成整个消息之前不知道长度,这可能需要很长时间。因此需要另一种机制来涵盖该用例:so-called“分块”。

在分块消息中,body 被发件人分成 arbitrary-length 块。每个块都以其长度开始,然后是 CRLF。发件人通过发送 zero-length 块(即包含字符 0 后跟 CRLF 的行)来指示消息已完全发送。

分块让发件人发送 dynamically-generated 长度未知的消息。它需要做的就是累积消息的一些字节并将它们作为一个块发送。它可以累积 fixed-length 缓冲区,或累积一段固定的时间,或使用任何其他标准。 (一些嵌入式天秤座即只是将每个 send() 调用变成一个单独的、通常非常小的块。那也没关系。分块没有语义功能;块的末尾可以在任何地方,甚至在多字节 UTF-8 代码的中间。)

这是对 HTTP 如何允许发送多条消息的简要概述;我遗漏了很多细节。如果你想写一个实现,你应该参考实际的协议规范。