多线程断点下载

来源:互联网 发布:顾比均线指标源码 编辑:程序博客网 时间:2024/06/07 18:07

多线程断点续传可以应用在上传和下载两个方面,好的上传或者下载案例,都是要求能够 “多线程 + 断点续传 + 进度条显示”。本篇是下载篇,主要是面向服务端程序员阐述原理。
做服务端开发或者web开发的同学,相比于安卓、IOS开发的同学,对于这个概念会比较陌生,因为web只能使用浏览器上传和下载,可编程性不高,客户端具体的上传和下载过程是个黑盒,比如下载过程到底是不是多线程,是不是支持断点续传,程序员都不用怎么关心。而安卓或者IOS的开发,客户端上传下载都是自己去面向服务端接口,自己读取本地IO流输出到服务端(上传),自己读取服务端IO流存储到本地(下载),比如关于下载的多线程、断点续传、进度条显示就需要程序员自己去考虑。


概述


试想一下,下载一个1000MB甚至更大的文件,我们可以从哪些角度去提升下载的体验呢?
1.缩短整个下载时间;
2.可以任意暂停开始,并且网络不稳定断开或者服务端超时断开,用户下一次不用重头开始下载;
3.下载进度显示。

问题1,可以从很多方面来考虑,除了提升网络带宽,一个有效的方案就是客户端多线程了,试想这样一个场景:客户端下载某一个服务端的1000MB的资源文件,这个服务端对外流量的输出有速率限制,限速策略是针对每个请求设置最大速率128KB/S。这时客户端的多线程就特别有效,如果客户端带宽充分,n个线程分段请求的速率就是n*128kb/s。


问题2,第一次客户端下载了500MB的临时文件,第二次请求500-999即可,这就是所谓的断点续传;

问题3,就更简单了,用当前下载大小/文件总大小就得到了进度。
如上针对问题1、2的方案就是多线程断点下载


http范围请求


多线程断点下载的核心原理就是依赖http协议的范围请求,从上述的方案分析,可以明显看出无论是多线程还是断点续传,都要求能够通过http协议访问某一段资源,这种范围请求是依靠一系列相对陌生的请求、响应消息头支持的。


1.Range:范围请求主要是通过Range消息头指定请求资源的字节范围,比如,上图表示请求[5000, 9999]字节。如果服务端不支持Range,则返回200 OK和全部资源;如果支持Range,则返回206 Partial Content和区间范围[5000, 9999]的资源,以及Accept-Range、Content-Range、Content-Length字段
2.Accept-Ranges:用来告诉客户端是否能处理范围请求,可指定的字段值有两种,可处理范围请求时指定其为bytes,反之指定其为none
3.Content-Range:5000-9999表示返回的实体资源的字节区间,10000表示资源的总大小。
4.Content-Length:实际返回的片段的大小

常见的Range格式还有:
Range: bytes=0-499 下载第0-499字节范围的内容
Range: bytes=500-999 下载第500-999字节范围的内容
Range: bytes=-500 下载最后500字节的内容
Range: bytes=500- 下载从第500字节开始到文件结束部分的内容


服务端


多线程断点续传的核心就是服务端的下载链接支持范围请求,那么我们平时给出的下载链接支不支持范围请求呢,我们又该如何测试呢?

回忆一下,我们平时对外提供下载链接主要有三种方案:
1.资源上传到第三方云存储,第三方云存储会返回资源的下载链接
2.资源上传至tomcat的静态资源目录,直接contextpath+资源相对路径,给出下载链接,即利用tomcat的DefaultServlet
3.自定义servlet读取对应的文件,输出给调用的客户端


测试服务端是否支持范围请求,我们可以使用360浏览器下载管理器、迅雷下载和curl程序:
360浏览器的下载使用的是迅雷下载技术,它就是一个典型的值得模仿的多线程断点续传下载客户端,原理大致是
1.发送一个非范围请求获取文件信息,包括文件名和文件大小。文件名称通过Content-Disposition的filename,没有Content-Disposition就通过下载链接,文件大小通过Content-Length获得。
2.尝试多线程,判断服务器端是否支持范围请求
3.不支持范围请求,就使用单线程下载,此时因为不支持范围请求,暂停继续功能是假的,后续测试案例会提到。
4.如果支持范围请求,使用多线程下载

curl可以指定Range消息头,curl --header "Range: bytes=0-20000" xxx.com/xxx.mp4,或者通过使用-C选项可对文件使用断点续传功能

经过测试,一般的云存储和DefaultServlet是支持范围请求的,下面主要分析自定义Servlet

public class DownloadServlet extends HttpServlet {    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {        /**         * 打印出请求头         */        Enumeration<String> headerNames = req.getHeaderNames();        while (headerNames.hasMoreElements()) {            String headerName = headerNames.nextElement();            System.out.println(headerName + ": " + req.getHeader(headerName));        }        System.out.println("-------------------------------------------");        File file = new File("D:/cache/1.mp4");        InputStream in = new FileInputStream(file);        OutputStream out = resp.getOutputStream();        resp.setHeader("Content-Disposition", "attachment;filename=123.mp4");        resp.setContentLength((int) file.length());        byte[] buffer = new byte[1024];        int len;        while ((len = in.read(buffer)) != -1) {            out.write(buffer, 0, len);        }        in.close();        out.close();    }}

360 case 1:注释掉resp.setContentLength((int) file.length());即不返回Content-Length


360 case 2:返回Content-Length


360 case 3:暂停再继续


curl case 1:curl --header "Range: bytes=0-200" -O http://localhost/download.do
下载的文件大小会超过201字节,curl没有判断服务端是否支持Ranges,照单全收
-O:服务端的消息体保存成文件

curl case 2:curl -C - -O http://localhost/download.do
执行一段时间后,中断,再次执行,第二次执行会报错
-C -:断点续传


两次请求对应的消息头


我们再来测试一个支持范围请求的链接,这里选择DefaultServlet,主要是得到返回的消息头
curl -C - -O -v  http://localhost/1.mp4
-v:显示详细的通信过程
这里给出第二次请求,即范围请求的头信息:


经过上面的测试case,我们得出结论:
DefaultServlet是支持范围请求的,通过查看tomcat/catalina.jar/org.apache.catalina.servlets.DefaultServlet源代码是能够看到对于Range的处理的
我们的自定义Servlet要想兼容各个客户端,获取好的下载体验,需要:
1.能够返回Content-Length
2.如果请求没有带有Range头,需要返回200和全部的资源
3.如果请求带有Range头,需要遵循范围请求相关的约定

改进后的代码:

public class StandardDownLoadServlet extends HttpServlet {    @Override    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {        /**         * 打印出请求头         */        Enumeration<String> headerNames = req.getHeaderNames();        while (headerNames.hasMoreElements()) {            String headerName = headerNames.nextElement();            System.out.println(headerName + ": " + req.getHeader(headerName));        }        System.out.println("-------------------------------------------");                resp.setHeader("Content-Disposition", "attachment;filename=123.mp4");        resp.setHeader("Accept-Ranges", "bytes");        File file = new File("D:/cache/1.mp4");        int fileLength = (int) file.length();        String rangeHeader = req.getHeader("Range");        /**         * 解析出Range信息封装到Range对象,根据请求有没有Range信息做不同的响应处理         */        Range range = this.parseRange(rangeHeader, fileLength);        if (range != null) {            resp.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + range.length);            resp.setContentLength(range.end - range.start + 1);            resp.setStatus(206);        } else {            resp.setContentLength(fileLength);            resp.setStatus(200);            range = new Range();            range.start = 0;            range.end = fileLength - 1;        }        InputStream is = new FileInputStream(file);        OutputStream out = resp.getOutputStream();        is.skip(range.start); // 分段读取文件的关键API        byte[] buffer = new byte[1024];        int length;        int total = range.end - range.start + 1;        while(total > 0 && (length = is.read(buffer)) != -1) {            if (total >= length) {                out.write(buffer, 0, length);                total -= length;            } else {                out.write(buffer, 0, total);                total = 0;            }        }        is.close();        out.close();    }    private Range parseRange(String rangeHeader, int fileLength) {        /**         * Range: bytes=0-499 下载第0-499字节范围的内容         * Range: bytes=500-999 下载第500-999字节范围的内容         * Range: bytes=-500 下载最后500字节的内容         * Range: bytes=500- 下载从第500字节开始到文件结束部分的内容         */        if (rangeHeader == null)            return null;        Range range = new Range();        String rangeStr = rangeHeader.substring(6);        String startStr = rangeStr.split("-")[0];        String endStr = rangeStr.endsWith("-")?"":rangeStr.split("-")[1];        if (startStr != "") {            range.start = Integer.parseInt(startStr);            if (endStr != "") {                range.end = Integer.parseInt(endStr);            } else {                range.end = fileLength - 1;            }        } else {            range.end = fileLength - 1;            range.start = range.end + Integer.parseInt(rangeStr);        }        range.length = fileLength;        return range;    }    private static class Range {        public int start;        public int end;        public int length;    }}
我们继续使用curl -C - -O -v http://localhost/download.do测试,发现第二次不报错了,也就是说支持范围请求,支持curl的断点续传了,这里给出第二次请求的相关头信息



客户端


多线程断点下载的客户端根据上面的原理分析,就很容易理解了,当然要想写出健全的代码还是很不容易的,要考虑的因素很多,对于支持范围请求与不支持范围请求的服务端都要能够兼容,像360浏览器的下载工具就做到了兼容,我这里就不给出完整的客户端代码了,我也没有认真写过健硕的客户端下载的代码,这里仅仅就关键API做一下介绍。
1.如果仅仅实现像curl的单线程的断点续传,那么要求我们能够对文件进行追加,可以通过public FileOutputStream(File file, boolean append),true表示在file的末尾追加内容。
2.如果要实现多线程的断点续传,我们让每个线程下载部分文件片断,之后要能够把这些片段合并,可以参考RandomAccessFile相关API,这个类能够满足多线程断点续传的需求,使用这个类的思路不是合并,而是一上来创建一个和服务端完整文件同样大小的随机读写的文件,然后每个线程向这个文件的特定片段填充从服务端下载到的内容。


2 0