我的HTTP下载库

发布 : 2017-10-02 分类 : diaries 浏览 : --

由于当时在写七牛云的终端管理助手,但当时七牛云对python SDK的定位一直都是服务端使用,没有对于我这样的终端使用者的考虑,所以在用了一段时间的官方SDK,在一次更新了SDK后,终端出现了很多的调试信息输出,于是终于忍不住开始自己适配七牛服务器的调用API,然而当时也还年轻,没有仔细看看requests,于是就开始了自己处理HTTP报文,一路上停停顿顿的,看看提交代码的history,竟然自那以来已经1年了!

现在这个http库的部分功能正在 qiniuManager 里发挥作用,或者说发挥最基本的发起HTTP请求和获取HTTP响应的作用,这个也是自己管理服务器远程备份的主要工具了(时不时偶尔登录七牛提供的网页工具,发现还是自己写的工具用起来比较顺手),所以如果出现什么问题的话,也能尽快修复(这个HTTP下载库)。以下将回忆记录下这个http下载库的坎坷路程。

先说说 http下载库 这个名字好了,好吧,其实是临时想出来的名字,实际上是一个和 urllib 功能相仿的小工具,然而功能却是完全根据自己的需要添加上去的,本身根本没有从项目中独立出来,毕竟怎么好意思把这么一个阉割版本的 urllib 工具拿出来。然而,正因为是阉割版本,只留下了自己需要的方法,某种程度上来说,算是精兵简政,再加上自己需要的方法,能让功能更紧凑,耦合程度略有提高。虽然如此,以下还是暂时用 HTTP下载库小工具 来指代它好了~

既然是 http库,最基本的功能当然是发送请求和获取响应,当然也就离不开自己构造请求报文和解析响应报文了。与这个小工具相耦合的功能,就是一个可以在终端显示出来的 进度条 ,这个功能也是要做这个小工具的重要目的之一。

在http请求中,这里指保留了最常用的 GETPOST 方法,或者说只考虑了最常用的 GET ,兼容了 POST 方法(后来发现post方法中多了两次换行结尾,导致服务器记录下错误信息,但是没有报 400错误 =.=,只是记录下了问题)。正常情况下,在完成请求行之后,再用 \r\n 连接 headers ,再考虑如果是 POST 方法的话,加上一个关于报文实体长度的 header : Content-Length: xxx ,在 headers 结束之后接一个 \r\n + 报文实体,最后把请求用TCP发送给服务器就好了,请求的过程就跟用 telnet 一样顺畅,最多再考虑考虑是否用 SSL 包裹 socket 就好了。

重点当然是在于获取并解析http响应报文。

由于响应报文 headers 中还有很多换行符 CRLF ,所以在一开始的时候比较偷懒,我把请求全都放进了一个 StringIO,然后一次一次的 readline。后来想用同样的方法处理 chunked 编码 ,发现想要得到当前stringio 已经缓存了多少数据很烦,于是就放弃了对 分块编码 的支持(为什么现在想起来一切都辣么简单,曾经却辣么复杂=。=)。最重要的问题是,StringIO 需要接收字符串作为数据输入,这对于py2来说没什么,但是对于py3就不是了,socket.recv 返回的数据为 bytes 类型,如果强行解码为字符串,对于二进制内容来说必定会导致错误,如果忽略错误,将导致数据丢失,这也是最终放弃了使用 StringIO 的原因,直接使用了 bytes

对于响应报文的headers,处理很平实,由于不知道是否把headers和响应行都接受完了,所以需要把一开始的响应都缓存起来,判断是否有headers的结束标志 CRLF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if not self.status:
self.raw_head += data
if b'\r\n\r\n' in self.raw_head: # 接收数据直到 `\r\n\r\n` 为止
seps = self.raw_head[0: self.raw_head.index(b'\r\n\r\n')].split(b'\r\n')
status = seps[0].split(b' ')
self.status = {
'status': seps[0],
'code': status[1],
'version': status[0]
}
if self.file_handle and not self.status['code'] == b'200':
self.clean_failed_file()
self.finish_loop()
return False
self.headers = {
i.split(b":")[0]: i.split(b":")[1].strip() for i in seps[1:]
}

对于报文实体,首先需要判断是否为分块编码,并由此决定了进度条的目标点 self.total

1
2
3
4
5
if b'Content-Length' in self.headers:
self.total = int(self.headers[b'Content-Length'])
else:
self.total = 100
self.chunked = True

当然,socket.recv 并不可能只把 响应行和headers 接收下来,多半会带有部分的报文实体,于是还有处理完headers之后剩下的实体部分:

1
left = self.raw_head[self.raw_head.index(b"\r\n\r\n") + 4:]

接下来就是接收实体部分了。对于普通编码的实体而言,只需要直接保存数据就好了,至于是保存进文件还是 bytes,由 save_data 来处理了;对于分块编码,在存储之前还需要把每一个分块的大小标识去除。

在处理 分块编码 时,仔细想想的话,这个分块大小标志并不能随便去除,否则不知道当前分块有多大,万一传输的数据中刚好就有类似的标志怎么办,然后就会导致数据丢失(这也是一开始觉得比较难处理的地方),还会有数据块中显示包含 \r\n 的情况;不能排除socket.recv了多少,可能出现下面的情况:

  • 刚好接收完一个分组:
1
2
2
ab
  • 接收了多个分组:
1
2
3
4
5
3
abc

4
abcd
  • 分组不完整
1
2
8f
lkajsdjl (后面还没收到)
  • 分组的大小标志断开

上一次socket.recv:

1
2
3
4
3
abc

7

这一次socket.recv

1
2
b
jlaksjd.....

7b 被分割到了两次recv中

  • 一次性接收完了所有分组:
1
2
3
4
3
abc

0

所以说,如果每当接收一次数据(socket.recv) 就开始处理的话,一定会出现很多情况需要处理,曾经因为这些复杂的情况,索性放弃了chunked编码的支持

然而要解决上面辣么多情况,其实也并不复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def flush_chunk(self, data):
self.current_chunk += data
self.progressed = random.randrange(20, 80)

while len(self.current_chunk) > 10240 or self.current_chunk.endswith(b'0\r\n\r\n'): # 并不意味着所有分块结束
# 开始解析当前chunk cache
chunk_head = self.current_chunk[: self.current_chunk.index(b'\r\n')]
chunk_left = self.current_chunk[self.current_chunk.index(b'\r\n') + 2:]
chunk_size = int(chunk_head, 16)
if chunk_size == 0:
self.finish_loop()
return True
if chunk_size > len(chunk_left):
# 说明当前分块没有接收完全
return False
self.save_data(chunk_left[: chunk_size])
self.current_chunk = chunk_left[chunk_size:]
if self.current_chunk.startswith(b'\r\n'):
# 如果上一个分块没有吃掉最后的 \r\n,则在这里把它剔除
self.current_chunk = self.current_chunk[2:]

嗯,就是需要缓存下一个足够进行下一步存储的缓存块,这样就没有了分块大小标志被生生分开的情况,并且如果当前分块的大小标志显示的大小要大于已经缓存的数据大小的话,说明这个分块还没有准备好被处理,需要进一步接收 socket.recv 的数据。self.current_chunk.endswith(b'0\r\n\r\n') 并不一定是响应结束的标志,因为不能排除这就是实体的一部分,一定要严格从分块编码的分块大小到分块实体的顺序,判断到显示分块大小为0,才能结束整个recv的循环,最后,剩下的部分由 finish_loop 来结束,里面包含着断开连接和关闭文件的工作,如果不关闭文件,测试的时候就会知道出什么问题了。由于当年没有想到还有缓存这一招,导致很长一段时间搁置了分块编码的支持,直到最近又开始自己的静态文件服务器的管理,一定需要支持分块编码,不得不好好想想了。现在的问题就是分块编码的时候怎么来显示进度条了。

顺便一说,由于服务器资源有限,对于很大的文件的传输,必须传输分块编码,不然内存就吃不消了,当前,这个网站的所有文章中的图片资源都是来自这个静态文件服务器,虽然下次升级的时候可能又是另一番功夫了。

以上,感觉还是有很多东西需要学习的。

现在有三个项目有依赖这个下载库,每次更新都要三个一起更新,,我在犹豫要不要把它独立成库了=。=

本文作者 : hellflame
原文链接 : https://hellflame.github.io/2017/10/02/my-http-connector/
版权声明 : 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
留下足迹
点击通过issue留言