断点下载
背景
断点续传/断点下载一直是每个系统最实用的功能,最近公司在复杂的网络环境(国外vps)下载东西遇到问题,有些文件下载的时候很慢,并且可能会下不下来,这种情况对一个系统的稳定性构成很大的威胁,所以必须要采用断点下载,并且需要自定义设置下载大小。
然而网上都是关于客户端下载的方法,没有关于服务端的实现方法。根据网络环境的复杂及网速设计了一个可以自定义下载大小的服务端/客户端代码。
原理
利用请求头和响应头
Range是一个HTTP请求头,告知服务器要返回文件的哪一部分,即:哪个区间范围(字节)的数据,在 Range 中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 头,从而返回整个文件,状态码用 200 。
当你正在看大片时,网络断了,你需要继续看的时候,文件服务器不支持断点的话,则你需要重新等待下载这个大片,才能继续观看。而Range支持的话,客户端就会记录了之前已经看过的视频文件范围,网络恢复之后,则向服务器发送读取剩余Range的请求,服务端只需要发送客户端请求的那部分内容,而不用整个视频文件发送回客户端,以此节省网络带宽。
Range 格式
Range: bytes=0-32768
交互过程
客户端请求下载时会带上Range头。Range 表示下载的范围区间,
0-1024
就是下载开始的1024bytes,1024-
就是从1024开始到结束,1024-32768
就是下载1024-32768
之间的bytes。
服务端会根据HTTP_RANGE
来获取客户端需要的文件大小和文件大小的范围,然后以Content-Range=0-1024/32768
来返回,/
之后代表的是整个文件的大小,之前代表的是当前的文件区间内容大小
废话不多说,上代码
代码实现
Server 端代码如下
import stat
from django.http import FileResponse, JsonResponse
import os
import re
import posixpath
import mimetypes
from django.utils._os import safe_join
# Create your views here.
def down_file_iterator(file_path, start_pos, chunk_size):
"""
文件生成器,防止文件过大,导致内存溢出
:param file_path: 文件绝对路径
:param start_pos: 文件读取的起始位置
:param chunk_size: 文件读取的块大小
:return: yield
"""
with open(file_path, mode='rb') as f:
f.seek(start_pos, os.SEEK_SET)
content = f.read(chunk_size)
yield content
def breakpoint_download(request, filename):
"""
断点下载
:param request: request
:param filename: 文件名
:return: response
"""
# 防止目录遍历漏洞
path = posixpath.normpath(filename).lstrip('/')
# 拼接文件路径 document_root: 文件根路径(自己设置,例如:/home/files/)
full_path = safe_join(document_root, path)
if os.path.exists(full_path):
stat_obj = os.stat(full_path)
# 获取文件类型
content_type, encoding = mimetypes.guess_type(full_path)
content_type = content_type or 'application/octet-stream'
# 计算读取文件的起始位置
start_bytes = re.search(r'bytes=(\d+)-(\d+)', request.META.get('HTTP_RANGE', ''), re.S)
# 重新计算文件起始位置,切分bytes=0-32768(bytes=temp_size-pos)
pos = start_bytes.group().split('=')[1] if start_bytes else 0
# 应该取temp_size为起始位置,不然始终差了32k,导致客户端一直下载不完
start_bytes = int(pos.split('-')[0]) if pos else 0
current_pos = int(pos.split('-')[1]) if pos else 0
# 打开文件并移动下标到七十五i之,客户端继续下载时,从上次断开的点继续读取
the_file = open(full_path, 'rb')
the_file.seek(start_bytes, os.SEEK_SET)
# status=200表示下载开始,status=206表示下载暂停后继续,为了兼容火狐浏览器而区分两种状态
response = FileResponse(the_file, content_type=content_type, status=206 if start_bytes > 0 else 200)
# 修改文件生成器返回指定大小
content_length = current_pos - start_bytes
response = FileResponse(down_file_iterator(full_path, start_bytes, content_length), content_type=content_type,
status=206 if start_bytes > 0 else 200)
# 这里的‘Content-Length’表示剩余待传输的文件字节长度
if stat.S_ISREG(stat_obj.st_mode):
# 计算每一包的content-length,如果返回指定大小,每包只能下载指定大小,根据客户端的请求返回指定大小
response['Content-Length'] = content_length
if encoding:
response['Content-Encoding'] = encoding
# ‘Content-Range’的'/'之前描述响应覆盖的文件字节范围,起始下标为0,'/'之后描述整个文件长度,与'HTTP_RANGE'对应使用
response['Content-Range'] = f'bytes {start_bytes}-{stat_obj.st_size-1}/{stat_obj.st_size}'
return response
else:
return JsonResponse({'code': 404, 'msg': 'File Not Found'})
client 端代码如下:
import os
import sys
import requests
def download_file(url, file_path, file_size):
"""
请求url将文件保存至file_path
:param url: 下载路径
:param file_path: 文件保存路径
:param file_size: 文件大小(也可以用head或者get去获取大小)
:return:
"""
if os.path.exists(file_path):
os.remove(file_path)
sys.stdout.write("\r[%s%s]%d%%" % ('█' * 0, '' * 50, 0))
sys.stdout.flush()
total_size = int(file_size)
temp_size = 0
while True:
# 请求网址,加入请求头
if (total_size - temp_size) < 32 * 1024:
pos = total_size
else:
# 每包下载32k这个可以根据自己的网络环境来设置
pos = temp_size + 32 * 1024
headers = {'Range': f'bytes={temp_size}-{pos}'}
response = requests.get(url, stream=True, verify=False, headers=headers)
if response.status_code == 200 or response.status_code == 206:
with open(file_path, 'ab') as f:
f.write(response.content)
# 终端显示进度
temp_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
done = int(50 * temp_size / total_size)
sys.stdout.write("\r[%s%s]%d%%" % ('█' * done, '' * (50 - done), 100 * temp_size / total_size))
sys.stdout.flush()
# 下载完成退出
if temp_size == total_size:
break
return file_path
下载测试文件
测试结果:
下载后暂停继续下载成功,每包下载32k这个可以根据自己的网络环境来设置
下载保存路径