手把手教你实现一个 Python 多线程下载器

前言

下载文件是我们生活中的一个常见的需求,因此衍生的下载工具也非常多,各有各的优势以及不足之处。作为一个编程爱好者,我喜欢去研究它们是怎么实现文件下载功能的。

我的主要使用的编程语言是 Python ,所以今天我将以 Python 为切入点带你一步步实现一个简易的文件下载器。 这个下载器将会分为几个版本

单线程不带进度条的下载器
单线程带进度条的下载器
多线程带进度条的下载器

如果你感兴趣,我们可以开始了!

懒得看过程的可以直接到文末取有详细注释的代码(开始之前这部分要看)。

开始之前

  1. Python版本要求
Python 3.7 及以上

2. 需要安装的库

tqdm
requests
retry
multitasking

库的安装方法是:打开 cmd(命令提示符或者其他终端工具),输入以下代码

pip install tqdm requests multitasking retry

输入完毕,按 Enter 键执行代码,等待 successfully 出现即可

正文

简易版文件下载器

基础知识

使用 requests 库发起请求

# 导入requests 库
import requests
# 请求链接
url = 'https://www.baidu.com/'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
# 发起请求
response = requests.get(url, headers=headers)

# 响应状态码
response.status_code
# 200

# 响应的文本内容
response.text
'''
\n\n\n 
...

'''

# 响应内容
response.content
'''
b'\n\n\n 
...

'''

# 响应头
response.headers
'''
{'Bdpagetype': '1', 'Bdqid': '0xa4413421000728b1'
...
'''

向文件中写入网络响应内容

# 导入requests 库
import requests
# 请求链接
url = 'https://www.baidu.com/'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
# 发起请求
response = requests.get(url, headers=headers)

content = response.content
file_name = 'hello.html'
# 以 wb 的模式打开文件
with open(file_name, mode='wb') as f:
    # 写入响应内容
    f.write(content)
print(f'写入文件成功!文件名 {file_name}')

实例操作

通过这学习上面的基础,我们实现了一个简易的文件下载器,下面继续来测试一个下载一个文件,文件直链如下

https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe

实例代码

# 导入requests 库
import requests
# 文件下载直链
url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
# 文件名
file_name = 'BaiduNetdisk_7.2.8.9.exe'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
print('正在下载文件......')
# 发起请求
response = requests.get(url, headers=headers)

content = response.content

# 以 wb 的模式打开文件
with open(file_name, mode='wb') as f:
    # 写入响应内容
    f.write(content)
print(f'文件下载成功!文件名 {file_name}')

运行输出

正在下载文件......
文件下载成功!文件名 BaiduNetdisk_7.2.8.9.exe

打开代码运行目录即可看到文件:BaiduNetdisk_7.2.8.9.exe

带进度条的文件下载器

基础知识

获取文件大小 不难知道,要做一个进度条展示下载进度,我们得事先知道文件的大小以及每次写入文件的大小。还是以这个文件链接为例,展示如何获取待下载的文件大小

# 导入requests 库
import requests
# 文件下载直链
url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}

# 发起 head 请求,即只会获取响应头部信息
response = requests.head(url, headers=headers)
# 文件大小,以 B 为单位
file_size = response.headers.get('Content-Length')
if file_size is not None:
    file_size = int(file_size)
print('文件大小:', file_size, 'B')

代码运行输出

文件大小: 67765560 B

分块获取响应内容

上面的文件大小为:67765560 B。如果我们一次性下载 67765560 B 这么大的文件的话,进度条的跨度显然太大了,那么做这么一个进度条的意义并不大,所以我们需要考虑通过一个循环,分多次连续地读取响应直到读取完毕。

# 导入requests 库
import requests
# 文件下载直链
url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}

# 发起 head 请求,即只会获取响应头部信息
head = requests.head(url, headers=headers)
# 文件大小,以 B 为单位
file_size = head.headers.get('Content-Length')
if file_size is not None:
    file_size = int(file_size)
response = requests.get(url, headers=headers, stream=True)
# 一块文件的大小
chunk_size = 1024
# 记录已经读取的文件大小
read = 0
for chunk in response.iter_content(chunk_size=chunk_size):
    read += chunk_size
    read = min(read, file_size)
    print(f'已读取: {read} 总大小: {file_size}')

部分输出结果

已读取: 1024 总大小: 67765560
已读取: 2048 总大小: 67765560
已读取: 3072 总大小: 67765560
已读取: 4096 总大小: 67765560
已读取: 5120 总大小: 67765560
已读取: 6144 总大小: 67765560
已读取: 7168 总大小: 67765560
已读取: 8192 总大小: 67765560

通过以上操作,我们成功实现分段、连续下载文件。但是输出显然太多了,我们更希望有一个单行的进度条。 这里不得不说到 tqdm 这个进度条库

一个 tqdm 的使用例子

import time
from tqdm import tqdm
total = 100
for _ in tqdm(range(total)):
    time.sleep(0.1)

在终端或者jupyter运行上面的代码即可看到下面的动图

手把手教你实现一个 Python 多线程下载器_第1张图片

如果想一次更新指定次数个进度怎么办呢?比如说进度总共是 100 ,每 10 个刷新一次进度

实现代码例子如下

import time
from tqdm import tqdm
# 总进度
total = 100
# 每次刷新的进度
step = 10
# 总共要刷新的次数
flush_count = total//step
bar = tqdm(total=total)
for _ in range(flush_count):
    time.sleep(0.1)
    bar.update(step)
bar.close()

以上代码可实现 每 10 个单位刷新一次直进度满 100

下面来给我们的下载器加进度条吧!

# 导入requests 库
import requests
# 导入 tqdm
from tqdm import tqdm
# 文件下载直链
url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
file_name = 'BaiduNetdisk_7.2.8.9.exe'
# 发起 head 请求,即只会获取响应头部信息
head = requests.head(url, headers=headers)
# 文件大小,以 B 为单位
file_size = head.headers.get('Content-Length')
if file_size is not None:
    file_size = int(file_size)
response = requests.get(url, headers=headers, stream=True)
# 一块文件的大小
chunk_size = 1024
bar = tqdm(total=file_size, desc=f'下载文件 {file_name}')
with open(file_name, mode='wb') as f:
    # 写入分块文件
    for chunk in response.iter_content(chunk_size=chunk_size):
        f.write(chunk)
        bar.update(chunk_size)
# 关闭进度条
bar.close()

代码运行过程如下

手把手教你实现一个 Python 多线程下载器_第2张图片

就这样,我们成功实现了一个带进度条的文件下载器! 为了调用方便,我们可以考虑把它封装为函数,这里我直接附上代码了

实例代码

函数形式的带进度条的单线程文件下载器

# 导入requests 库
import requests
# 导入 tqdm
from tqdm import tqdm


def download(url: str, file_name: str):
    '''
    根据文件直链和文件名下载文件

    Parameters
    ----------
    url: 文件直链
    file_name : 文件名(文件路径)

    '''
    # 文件下载直链
    # 请求头
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
    }

    # 发起 head 请求,即只会获取响应头部信息
    head = requests.head(url, headers=headers)
    # 文件大小,以 B 为单位
    file_size = head.headers.get('Content-Length')
    if file_size is not None:
        file_size = int(file_size)
    response = requests.get(url, headers=headers, stream=True)
    # 一块文件的大小
    chunk_size = 1024
    bar = tqdm(total=file_size, desc=f'下载文件 {file_name}')
    with open(file_name, mode='wb') as f:
        # 写入分块文件
        for chunk in response.iter_content(chunk_size=chunk_size):
            f.write(chunk)
            bar.update(chunk_size)
    # 关闭进度条
    bar.close()


if "__main__" == __name__:
    url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
    file_name = 'BaiduNetdisk_7.2.8.9.exe'
    download(url, file_name)

多线程文件下载器

絮絮叨叨

早些年还没会编程的时候,我喜欢寻找各种高速的下载工具,比如说简单易用的 IDM 以及命令行多线程下载工具 Aria2。它们的下载速度确实让人惊艳。通过查询一些资料,我大概了解到它们下载速度快的原因之一是它们对同一个服务器建立多个连接,然后分块请求响应的内容,于是人多力量大,下载速度自然就提高了。

基础知识

假设我们有下面这么一段代码

单线程程序

import time


def say(number: int):
    print(number)
    time.sleep(0.5)


for i in range(5):
    say(i)

代码运行输出

0
1
2
3
4

不难发现,该代码实现的功能是每间隔 0.5 秒输出一次数字,一共重复 5 次。所以这段程序耗时在 2.5 秒左右。 如果我们使用多线程呢?譬如对于每一次操作均开启一个线程,结果会是怎么样?

为了简化多线程的写法,我查阅了相关资料,发现了一个很好用的多线程库

multitasking

如果你没有安装它,则可以使用 pip 工具,在终端运行下面的代码进行安装(前提是 pip 安装目录在环电脑境变量里面)

pip install multitasking

使用它之后,我们只需要给自定义的函数前面加上一行代码(装饰器)即可在调用函数时,为被调用的这个函数开启新的线程。下面是一个使用例子

以上代码的多线程版本如下

import time
# 导入用于多线程操作的库
# 这样子仅需要在自定义的函数前面使用装饰器即可将函数开启新的线程
import multitasking
import signal
# 按快捷键 ctrl + c 终止已开启的全部线程
signal.signal(signal.SIGINT, multitasking.killall)

# 多线程装饰器


@multitasking.task
def say(number: int):
    print(number)
    time.sleep(0.5)


start_time = time.time()
for i in range(5):
    say(i)
# 等待全部线程执行完毕
multitasking.wait_for_tasks()
end_time = time.time()
print('耗时:', end_time-start_time, '秒')

代码运行输出

0
1
2
3
4
耗时: 0.5063784122467041 秒

同样是每 0.5 秒输出一个数字,上面的代码因为使用多线程,耗时只有 0.5 秒左右,而之前的单线程版本耗时是 2.5 秒左右。

这里面用了一个装饰器,要展开的话稍微有些麻烦,感兴趣的先自己去了解或者与我交流吧。在这篇文章中,我们只需要掌握怎么简单地使用它来完成一个多线程操作即可。

示例操作

回顾之前写的单线程版本下载器,我们知道怎么获取待下载的文件大小以及如何分块下载。注意上面的分块下载是仅有一个线程在操作的,譬如文件大小为: 1000 B,每次下载 100 B,那么单线程会连续地每次读取 100 B 的内容,直到没有内容可读取。

为了能让多线程下载同一个文件,我们需要为每一个线程分配属于它自己的任务,比如说要下载大小为 100 B的文件,那么线程一可以负责下载 0-50 B,线程二负责下载 50-100 B,这样子分两个线程来下载。

要实现任务以上的任务分配。我们自然得学会怎么将一个数字分为多个区间,下面以将 100 分为每一块 20 为例,展示怎么实现这样子的功能

# 总大小
total = 100
# 每一块的大小
step = 20
# 分多块
parts = [(start, min(start+step,total)) for start in range(0, total, step)]
print(parts)

运行上面的代码,会达到以下输出

[(0, 20), (20, 40), (40, 60), (60, 80), (80, 100)]

可以看到,我们成功地完成了较为完美的切割操作! 同样地,为了能够更好地维护代码,我们可以尝试把它抽取为函数,示例如下

from __future__ import annotations


def split(start: int, end: int, step: int) -> list[tuple[int, int]]:
    '''
    将指定区间的数切割为多个区间

    Parameters
    ----------
    start :起始位置
    end : 终止位置
    step : 区间长度

    Return
    ------
    区间元组构成的列表
    '''
    # 分多块
    parts = [(start, min(start+step, end))
             for start in range(0, end, step)]

    return parts


if "__main__" == __name__:
    # 起始位置
    start = 1
    # 终止位置
    total = 102
    # 区间长度
    step = 20
    parts = split(start, total, step)
    print(parts)

以上操作是为了后续分段下载文件做准备

下载部分文件

下面我将以下面这个链接为例

https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe

演示如何下载某一部分的文件

示例代码

import requests
# 待下载部分的文件起始位置
start = 0
# 待下载部分的文件终止位置
end = 1000
# 每次读取的大小
chunk_size = 128
# 记录下载的位置
seek = start
# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
# 这是核心!设定下载的范围
headers['Range'] = f'bytes={start}-{end}'
# 下载链接
url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'

response = requests.get(url, headers=headers, stream=True)
for chunk in response.iter_content(chunk_size=chunk_size):
    _seek = min(seek+chunk_size, end)
    print(f'下载: {seek}-{_seek}')
    seek = _seek

代码运行输出

下载: 0-128
下载: 128-256 
下载: 256-384 
下载: 384-512 
下载: 512-640 
下载: 640-768 
下载: 768-896 
下载: 896-1000

有了以上基础,我们来尝试把之前的单线程下载器修改为多线程的(我还没想好怎么一步步教,先贴有详细注释的代码吧,后续更新)


最终代码(带进度条的多线程下载器)

from __future__ import annotations
# 用于显示进度条
from tqdm import tqdm
# 用于发起网络请求
import requests
# 用于多线程操作
import multitasking
import signal
# 导入 retry 库以方便进行下载出错重试
from retry import retry
signal.signal(signal.SIGINT, multitasking.killall)

# 请求头
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
# 定义 1 MB 多少为 B
MB = 1024**2


def split(start: int, end: int, step: int) -> list[tuple[int, int]]:
    # 分多块
    parts = [(start, min(start+step, end))
             for start in range(0, end, step)]
    return parts


def get_file_size(url: str, raise_error: bool = False) -> int:
    '''
    获取文件大小

    Parameters
    ----------
    url : 文件直链
    raise_error : 如果无法获取文件大小,是否引发错误

    Return
    ------
    文件大小(B为单位)
    如果不支持则会报错

    '''
    response = requests.head(url)
    file_size = response.headers.get('Content-Length')
    if file_size is None:
        if raise_error is True:
            raise ValueError('该文件不支持多线程分段下载!')
        return file_size
    return int(file_size)


def download(url: str, file_name: str, retry_times: int = 3, each_size=16*MB) -> None:
    '''
    根据文件直链和文件名下载文件

    Parameters
    ----------
    url : 文件直链
    file_name : 文件名
    retry_times: 可选的,每次连接失败重试次数
    Return
    ------
    None

    '''
    f = open(file_name, 'wb')
    file_size = get_file_size(url)

    @retry(tries=retry_times)
    @multitasking.task
    def start_download(start: int, end: int) -> None:
        '''
        根据文件起止位置下载文件

        Parameters
        ----------
        start : 开始位置
        end : 结束位置
        '''
        _headers = headers.copy()
        # 分段下载的核心
        _headers['Range'] = f'bytes={start}-{end}'
        # 发起请求并获取响应(流式)
        response = session.get(url, headers=_headers, stream=True)
        # 每次读取的流式响应大小
        chunk_size = 128
        # 暂存已获取的响应,后续循环写入
        chunks = []
        for chunk in response.iter_content(chunk_size=chunk_size):
            # 暂存获取的响应
            chunks.append(chunk)
            # 更新进度条
            bar.update(chunk_size)
        f.seek(start)
        for chunk in chunks:
            f.write(chunk)
        # 释放已写入的资源
        del chunks

    session = requests.Session()
    # 分块文件如果比文件大,就取文件大小为分块大小
    each_size = min(each_size, file_size)

    # 分块
    parts = split(0, file_size, each_size)
    print(f'分块数:{len(parts)}')
    # 创建进度条
    bar = tqdm(total=file_size, desc=f'下载文件:{file_name}')
    for part in parts:
        start, end = part
        start_download(start, end)
    # 等待全部线程结束
    multitasking.wait_for_tasks()
    f.close()
    bar.close()


if "__main__" == __name__:
    # url = 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0d/ea/f936c14b6e886221e53354e1992d0c4e0eb9566fcc70201047bb664ce777/tensorflow-2.3.1-cp37-cp37m-macosx_10_9_x86_64.whl#sha256=1f72edee9d2e8861edbb9e082608fd21de7113580b3fdaa4e194b472c2e196d0'
    url = 'https://issuecdn.baidupcs.com/issue/netdisk/yunguanjia/BaiduNetdisk_7.2.8.9.exe'
    file_name = 'BaiduNetdisk_7.2.8.9.exe'
    # 开始下载文件
    download(url, file_name)

代码运行过程

手把手教你实现一个 Python 多线程下载器_第3张图片

写在最后

本文内容丰富,涉及到的主要知识有:文件读写、发起网络请求、多线程操作、代码出错重试。由浅入深,层层递进,最终实现了一个简易的带进度条的多线程下载器。

手把手教你实现一个 Python 多线程下载器_第4张图片

 

你可能感兴趣的:(python)