目标
客户端存在软件更新、UI版本更新等需求,在软件或者UI图片包更新的时候,可能出现网络不稳定、用户手动关机等情况,这样会导致下载中断。
因此,要支持客户断点下载这些文件,减少下载所需的容量。
原理
断点下载的主要技术点(服务端)包括两个:
- Range
- 链式流(管道流)
接下来,我会依次介绍它们。
Range
所谓断点下载,无非就是正常下载被打断后、下次下载要从被打断的位置开始继续下载。这个 "被打断的位置"客户端自己会记住,但怎么才能告诉服务器要从哪个位置开始提供下载内容呢?
这就需要客户端在发送请求的同时,把Range放在Header里一起发到服务器。服务器会解析这个Range,从而发送指定范围的文件内容给客户端。
举个例子:客户端要下载一个名为"test.mp3"的文件,文件大小为300字节。在下载完99字节的时候,客户端意外断网;这时候,客户端再次发出下载的请求,并告诉服务器,我要下载Range为100-299字节的文件内容,服务端就会准备好"test.mp3"文件的100-299字节部分。
那么问题来了,服务端是怎么把文件的99-299字节部分取出来的?
链式流(管道流)
在Node.js中,有一个概念叫 管道流。我们把文件看作一桶水,客户端是另一个空的桶;而管道操作,就相当于用一根水管,把服务端的一桶水(文件)连接到了客户端的一个空桶。通过管道操作,文件就会一点一点地流向客户端。
上面一节所说的,要取出一个文件的100-299字节部分,可以用管道的方式,从100开始流向客户端,直至流完所有的文件内容。
其他问题
HTTP/1.1。注意到,断点功能是在 HTTP/1.1 之后才支持的,在之前的协议是不支持断点功能的。
告知客户端。服务端要先告知客户端支持Range,之后客户端才能发起带Range的请求。在node.js中,可以设置Header:
arduino
response.setHeader('Accept-Ranges', 'bytes');
Range的请求头和响应头格式
Range的请求头格式
先来看一个简单的请求头例子:
vbnet
GET /donwload HTTP/1.1
Connection: close
Host: xxx.xx.xx.xx
Range: bytes=0-
注意到,这里"Range"设置为 Range: bytes=0-
意味着要下载整个文件;如果是要下载整个文件,其实可以不进行Range的设置,因为默认就是下载整个文件。事实上,设置Range头的目的就是断点下载/上传的时候告知服务器还从什么位置开始下载/上传。
下面为Range的请求头格式:
sql
Range: bytes=[start]-[end][,[start]-[end]]
根据上面的格式,我们可以知道Range可能出现下面几种情况:
ini
Range: bytes=0-99
Range: bytes=100-
Range: bytes=-200
Range: bytes=100-199,260-300
Range: bytes=0-99
表示客户端需求0-99字节的数据 Range: bytes=100-
表示客户端需要100字节及以后的数据 Range: bytes=-200
表示客户端需要最后200字节数据 Range: bytes=100-199,260-300
表示客户端需要100-199字节和260-300字节的数据
在第一节里,客户端在下载完0-99字节后,意外断网;客户端重新联网后,应该发出怎样的http请求头呢?
注意到,客户端已经下载了99字节了,那么应该从第100字节开始,下载剩下的内容。一般来说,客户端已经知道了要下载资源的总大小,那么Range就可以设置为 100-300
;但如果不知道,就可以将Range设置为 100-
,即不写 end
vbnet
GET /donwload HTTP/1.1
Connection: close
Host: xxx.xx.xx.xx
Range: bytes=100-
Range的响应头格式
先来看一个简单的Range响应头例子:
makefile
HTTP/1.1 200 OK
Content-Length: 300
Content-Type: application/octet-stream
Content-Range: bytes 0-299/300
注意到,这里有两个重要的头信息:Content-Length
和 Content-Range
。
Content-Length 。Content-Length
表示这次服务器响应了多少个字节的数据(上面例子中为300,即0-299)
Content-Range 。Content-Range
表示服务器响应的数据范围及该资源一共有个字节。(上面例子中响应的数据范围是0-299,资源总大小是300)
除了上面所示的几个头信息,还可能有:
Last-Modified : Last-Modified
表示资源最近修改的时间。这个信息的意义在于,如果断点续传时发现最近修改时间变化了,很可能是原始资源有变化,这样就必须重新下载,以保证文件不会损坏。
ETag : ETag
表示资源版本的标识符,可以是消息摘要。通过一些算法(如MD5)将资源文件的特征提取出来;由于每个文件提取出的特征都不会是一样的,因此可以通过它来验证某一个下载后的文件是否和原始文件为同一个文件。
在第一节中,客户端在断开后重新发出了继续下载的请求,服务器的响应头应该是怎样的呢?
makefile
HTTP/1.1 206 Partial Content
Content-Length: 200
Content-Type: application/octet-stream
Content-Range: bytes 100-299/300
注意到,响应的状态码不再是200了,而是 206
,表示 Partial Content
。如果客户端传来的Range值无效,则服务端会返回 416
状态码,表示 Request Range Not
。
此外,Content-Length
给出的是本次响应的数据大小,而不是这个文件的总大小;文件的总大小是在 Content-Range
末给出的。
实现断点下载
javascript
/**
* @Type : Model
* @Module : Downloader
* @Brief : Provide time function
* @Author : Linxiaozhou
* @Date : 2017/02/23
*/
// 中间件
var path = require('path');
var fs = require("fs");
var OL_Downloader = function() {};
/* ************ 内部使用函数 ************ */
/**
*@Brief: 规定MIME
*/
var OL_Mimes = {
"css" : "text/css",
"gif" : "image/gif",
"html" : "text/html",
"js" : "text/javascript",
"tiff" : "image/tiff",
"xml" : "text/xml",
"json" : "application/json",
"txt" : "text/plain",
"ico" : "image/x-icon",
"jpeg" : "image/jpeg",
"jpg" : "image/jpeg",
"png" : "image/png",
"svg" : "image/svg+xml",
"pdf" : "application/pdf",
"wav" : "audio/x-wav",
"flac" : "audio/x-flac",
"wma" : "audio/x-ms-wma",
"mp3" : "audio/mpeg",
"wmv" : "video/x-ms-wmv",
"mp4" : "video/x-ms-mp4",
"mov" : "video/quicktime",
"avi" : "video/x-msvideo",
"mpg" : "video/mpeg",
"mpeg" : "video/mpeg",
"mpe" : "video/mpeg",
"mpa" : "video/mpeg",
"mp2" : "video/mpeg",
"swf" : "application/x-shockwave-flash",
"zip" : "application/zip",
"tar" : "application/x-tar",
"gz" : "application/x-hdf",
"gtar" : "application/x-gtar",
"tgz" : "application/x-compressed",
"ppt" : "application/vnd.ms-powerpoint",
"pptx" : "application/vnd.ms-powerpoint",
"xls" : "application/vnd.ms-excel",
"xlsx" : "application/vnd.ms-excel",
"doc" : "application/msword",
"docx" : "application/msword",
"dll" : "application/x-msdownload",
"bin" : "application/octet-stream",
"exe" : "application/octet-stream",
};
/**
*@Name: OL_ParseHttpRange
*@Brief: 解析http头的Range信息
*/
var OL_ParseHttpRange = function (str, size) {
if (str.indexOf(",") != -1) {
return -1;
}
// str原始值为:"bytes=xxxx-xxxxxx";先把"bytes="去掉后再分割起止值
str = str.split("=")[1];
var range = str.split("-"),
start = parseInt(range[0], 10),
end = parseInt(range[1], 10);
// Case: -100
if (isNaN(start)) {
start = size - end;
end = size - 1;
// Case: 100-
} else if (isNaN(end)) {
end = size - 1;
}
// Invalid
if (isNaN(start) || isNaN(end) || start > end || end > size) {
return -2;
}
return {
start: start,
end: end
};
};
/* ********* 外部接口 ********* */
/**
*@Func: 断点下载
*@Name: ProcDownload
*@Input1: relative_url - 文件所在的相对路径,以项目根目录为参照
*@Input2: name - 要下载的文件名
*/
OL_Downloader.prototype.ProcDownload = function(relative_url, name, reqres) {
var realPath = relative_url+name;
var req = reqres.req,
res = reqres.res,
next = reqres.next;
// 获取 Content-Type
var ext = path.extname(realPath);
ext = ext ? ext.slice(1) : 'unknown';
var contentType = OL_Mimes[ext] || "text/plain";
fs.stat(realPath, function(err, stats){
if(undefined == stats){
res.render('error', {
message: "File Not Existed",
error: "Please contact the administrator for new url!"
});
return 0;
}
if (req.headers["range"]) {
// 非第一次下载(断点下载)
var range = OL_ParseHttpRange(req.headers["range"], stats.size);
if (range) {
var len = (range.end - range.start + 1);
// 设置响应头
res.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + stats.size);
res.setHeader("Content-Length", len);
res.setHeader('Content-Type', contentType);
res.writeHead(206, "Partial Content");
// 建立管道流
fs.createReadStream(realPath, {
"start" : range.start,
"end" : range.end
}).pipe(res);
} else {
res.removeHeader("Content-Length");
res.writeHead(416, "Request Range Not Satisfiable");
res.end();
}
} else {
// 第一次下载(正常下载)
// 设置响应头
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader("Content-Disposition", "attachment; filename="+encodeURIComponent(name));
res.setHeader("Content-Length", stats.size);
// 建立管道流
fs.createReadStream(realPath).pipe(res);
}
})
};
module.exports = new OL_Downloader();
拓展:多线程下载
多线程下载的原理和断点下载略有不同。多线程下载首先要获取整个文件的大小,然后开多个线程来下载这个文件的不同部分。
还是拿第一节的例子:客户端要下载一个名为"test.mp3"的文件,文件大小为300字节。如果我们可以使用3个线程来下载,那么:
- 线程1 :
Range: bytes=0-99
,下载到客户端,保存文件名为test-01.mp3 - 线程2 :
Range: bytes=100-199
,下载到客户端,保存文件名为test-02.mp3 - 线程3 :
Range: bytes=200-299
,下载到客户端,保存文件名为test-03.mp3
每一个线程的都下载完毕后,将 test-01.mp3
、 test-03.mp3
和 test-03.mp3
合并成一个文件,并命名为 test.mp3
即可。