Node.js断点下载和多线程下载原理

目标

客户端存在软件更新、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-LengthContent-Range

Content-LengthContent-Length 表示这次服务器响应了多少个字节的数据(上面例子中为300,即0-299)

Content-RangeContent-Range 表示服务器响应的数据范围及该资源一共有个字节。(上面例子中响应的数据范围是0-299,资源总大小是300)

除了上面所示的几个头信息,还可能有:

Last-ModifiedLast-Modified表示资源最近修改的时间。这个信息的意义在于,如果断点续传时发现最近修改时间变化了,很可能是原始资源有变化,这样就必须重新下载,以保证文件不会损坏。

ETagETag 表示资源版本的标识符,可以是消息摘要。通过一些算法(如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个线程来下载,那么:

  • 线程1Range: bytes=0-99,下载到客户端,保存文件名为test-01.mp3
  • 线程2Range: bytes=100-199,下载到客户端,保存文件名为test-02.mp3
  • 线程3Range: bytes=200-299,下载到客户端,保存文件名为test-03.mp3

每一个线程的都下载完毕后,将 test-01.mp3test-03.mp3test-03.mp3合并成一个文件,并命名为 test.mp3 即可。

参考文档

  1. Node.js断点下载
  2. Http 协议中的Range请求头例子
  3. http断点续传原理:http头 Range、Content-Range
相关推荐
许野平1 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
齐 飞3 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
LunarCod3 小时前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
码农派大星。4 小时前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man5 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*5 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu5 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s5 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子5 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王5 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构