没错,你没有看错,是前端多线程,而不是Node
。这一次的探索起源于最近开发中,有遇到视频流相关的开发需求发现了一个特殊的状态码,他的名字叫作 206
~css
为了防止本文的枯燥,先上效果图镇文。(以一张3.7M
大小的图片为例)。html
动画效果对比(单线程-左 VS 10个线程-右)前端
时间对比(单线程 VS 10个线程)node
看到这里是否是有点心动,那么请你继续听我道来,那咱们先抓个包来看看整个过程是怎么发生的。ios
`GET /360_0388.jpg HTTP/1.1 Host: limit.qiufeng.com Connection: keep-alive ... Range: bytes=0-102399 HTTP/1.1 206 Partial Content Server: openresty/1.13.6.2 Date: Sat, 19 Sep 2020 06:31:11 GMT Content-Type: image/jpeg Content-Length: 102400 .... Content-Range: bytes 0-102399/3670627 ...(这里是文件流)`
能够看到请求这里多出一个字段 Range: bytes=0-102399
,服务端也多出一个字段Content-Range: bytes 0-102399/3670627
,以及返回的 状态码为 206
.nginx
那么Range
是什么呢?还记得前几天写过一篇文章,是关于文件下载的,其中有提到大文件的下载方式,有个叫 Range
的东西,可是上一篇做为系统性地介绍文件下载的概览,所以没有对range
进行详细介绍。c++
如下全部代码均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple
Range
是在 HTTP/1.1 中新增的一个字段,这个特性也是咱们使用的迅雷等支持多线程下载以及断点下载的核心机制。(介绍性的文案,摘录了一下)git
首先客户端会发起一个带有Range: bytes=0-xxx
的请求,若是服务端支持 Range,则会在响应头中添加Accept-Ranges: bytes
来表示支持 Range 的请求,以后客户端才可能发起带 Range 的请求。github
服务端经过请求头中的Range: bytes=0-xxx
来判断是不是进行 Range 处理,若是这个值存在并且有效,则只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content,并设置Content-Range。若是无效,则返回416状态码,代表Request Range Not Satisfiable。若是请求头中不带 Range,那么服务端则正常响应,也不会设置 Content-Range 等。chrome
Range的格式为:
Range:(unit=first byte pos)-[last byte pos]
即Range: 单位(如bytes)= 开始字节位置-结束字节位置
。
咱们来举个例子,假设咱们开启了多线程下载,须要把一个5000byte的文件分为4个线程进行下载。
服务器给出响应:
第1个响应
第2个响应
第3个响应
第4个响应
若是每一个请求都成功了,服务端返回的response头中有一个 Content-Range 的字段域,Content-Range 用于响应头,告诉了客户端发送了多少数据,它描述了响应覆盖的范围和整个实体长度。通常格式:
Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]
即Content-Range:字节 开始字节位置-结束字节位置/文件大小
。
主流浏览器目前都支持这个特性。
在版本nginx版本 1.9.8 后,(加上 ngx_http_slice_module)默认自动支持,能够将 max_ranges
设置为 0
的来取消这个设置。
Node 默认不提供 对 Range
方法的处理,须要本身写代码进行处理。
router.get('/api/rangeFile', async(ctx) => { const { filename } = ctx.query; const { size } = fs.statSync(path.join(__dirname, './static/', filename)); const range = ctx.headers['range']; if (!range) { ctx.set('Accept-Ranges', 'bytes'); ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename)); return; } const { start, end } = getRange(range); if (start >= size || end >= size) { ctx.response.status = 416; ctx.body = ''; return; } ctx.response.status = 206; ctx.set('Accept-Ranges', 'bytes'); ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`); ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end }); })
或者你可使用 koa-send
这个库。
https://github.com/pillarjs/send/blob/0.17.1/index.js#L680
架构总览
咱们先来看下流程架构图总览。单线程很简单,正常下载就能够了,不懂的能够参看我上一篇文章。多线程的话,会比较麻烦一些,须要按片去下载,下载好后,须要进行合并再进行下载。(关于blob等下载方式依旧能够参看上一篇)
服务端代码
很简单,就是对Range
作了兼容。
router.get('/api/rangeFile', async(ctx) => { const { filename } = ctx.query; const { size } = fs.statSync(path.join(__dirname, './static/', filename)); const range = ctx.headers['range']; if (!range) { ctx.set('Accept-Ranges', 'bytes'); ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename)); return; } const { start, end } = getRange(range); if (start >= size || end >= size) { ctx.response.status = 416; ctx.body = ''; return; } ctx.response.status = 206; ctx.set('Accept-Ranges', 'bytes'); ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`); ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end }); })
html
而后来编写 html ,这没有什么好说的,写两个按钮来展现。
<!-- html --> <button id="download1">串行下载</button> <button id="download2">多线程下载</button> <script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
js公共参数
const m = 1024 * 520; // 分片的大小 const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg'; // 要下载的地址
单线程部分
单线程下载代码,直接去请求以blob
方式获取,而后用blobURL
的方式下载。
download1.onclick = () => { console.time("直接下载"); function download(url) { const req = new XMLHttpRequest(); req.open("GET", url, true); req.responseType = "blob"; req.onload = function (oEvent) { const content = req.response; const aTag = document.createElement('a'); aTag.download = '360_0388.jpg'; const blob = new Blob([content]) const blobUrl = URL.createObjectURL(blob); aTag.href = blobUrl; aTag.click(); URL.revokeObjectURL(blob); console.timeEnd("直接下载"); }; req.send(); } download(url); }
多线程部分
首先发送一个 head 请求,来获取文件的大小,而后根据 length 以及设置的分片大小,来计算每一个分片是滑动距离。经过Promise.all
的回调中,用concatenate
函数对分片 buffer 进行一个合并成一个 blob,而后用blobURL
的方式下载。
// script function downloadRange(url, start, end, i) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); req.open("GET", url, true); req.setRequestHeader('range', `bytes=${start}-${end}`) req.responseType = "blob"; req.onload = function (oEvent) { req.response.arrayBuffer().then(res => { resolve({ i, buffer: res }); }) }; req.send(); }) } // 合并buffer function concatenate(resultConstructor, arrays) { let totalLength = 0; for (let arr of arrays) { totalLength += arr.length; } let result = new resultConstructor(totalLength); let offset = 0; for (let arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } download2.onclick = () => { axios({ url, method: 'head', }).then((res) => { // 获取长度来进行分割块 console.time("并发下载"); const size = Number(res.headers['content-length']); const length = parseInt(size / m); const arr = [] for (let i = 0; i < length; i++) { let start = i * m; let end = (i == length - 1) ? size - 1 : (i + 1) * m - 1; arr.push(downloadRange(url, start, end, i)) } Promise.all(arr).then(res => { const arrBufferList = res.sort(item => item.i - item.i).map(item => new Uint8Array(item.buffer)); const allBuffer = concatenate(Uint8Array, arrBufferList); const blob = new Blob([allBuffer], {type: 'image/jpeg'}); const blobUrl = URL.createObjectURL(blob); const aTag = document.createElement('a'); aTag.download = '360_0388.jpg'; aTag.href = blobUrl; aTag.click(); URL.revokeObjectURL(blob); console.timeEnd("并发下载"); }) }) }
完整示例
https://github.com/hua1995116/node-demo
`// 进入目录 cd file-download // 启动 node server.js // 打开 http://localhost:8888/example/download-multiple/index.html`
因为谷歌浏览器在 HTTP/1.1 对于单个域名有所限制,单个域名最大的并发量是 6.
这一点能够在源码以及官方人员的讨论中体现。
讨论地址
https://bugs.chromium.org/p/chromium/issues/detail?id=12066
Chromium 源码
// https://source.chromium.org/chromium/chromium/src/+/refs/tags/87.0.4268.1:net/socket/client_socket_pool_manager.cc;l=47 // Default to allow up to 6 connections per host. Experiment and tuning may // try other values (greater than 0). Too large may cause many problems, such // as home routers blocking the connections!?!? See http://crbug.com/12066. // // WebSocket connections are long-lived, and should be treated differently // than normal other connections. Use a limit of 255, so the limit for wss will // be the same as the limit for ws. Also note that Firefox uses a limit of 200. // See http://crbug.com/486800 int g_max_sockets_per_group[] = { 6, // NORMAL_SOCKET_POOL 255 // WEBSOCKET_SOCKET_POOL };
所以为了配合这个特性我将文件分红6个片断,每一个片断为520kb
(没错,写个代码都要搞个爱你的数字),即开启6个线程进行下载。
我用单个线程和多个线程进行分别下载了6次,看上去速度是差很少的。那么为何和咱们预期的不同呢?
我开始仔细对比两个请求,观察这两个请求的速度。
6个线程并发
单个线程
咱们按照3.7M 82ms 的速度来算的话,大约为 1ms 下载 46kb,而实际状况能够看到,533kb ,平均就要下载 20ms 左右(已经刨去了链接时间,纯 content 下载时间)。
我就去查找了一些资料,明白了有个叫作下行速度和上行速度的东西。
网络的实际传输速度要分上行速度和下行速度, 上行速率就是发送出去数据的速度,下行就是收到数据的速度。ADSL是根据咱们平时上网,发出数据的要求相对下载数据的较小这种习惯来实现的一种传输方式。咱们说对于4M的 宽带,那么咱们的l理论最高下载速度就是512K/S,这就是所说的下行速度。 --百度百科
那咱们如今的状况是怎么样的呢?
把服务器比做一根大水管,我来用图模拟一下咱们单个线程和多个线程下载的状况。左侧为服务器端,右侧为客户端。(如下全部状况都是考虑理想状况下,只是为了模拟过程,不考虑其余一些程序的竞态影响。)
单线程
多线程
没错,因为咱们的服务器是一根大水管,流速是必定的,而且咱们客户端没有限制。若是是单线程跑的话,那么会跑满用户的最大的速度。若是是多线程呢,以3个线程为例子的话,至关于每一个线程都跑了原先线程三分之一的速度。合起来的速度和单个线程是没有差异的。
下面我就分几种状况来说解一下,什么样的状况才咱们的多线程才会生效呢?
这种状况其实咱们遇到的状况差很少的。
若是服务器限制了单个宽带的下载速度,大部分也是这种状况,例如百度云就是这样,例如明明你是 10M 的宽带,可是实际下载速度只有 100kb/s ,这种状况下,咱们就能够开启多线程去下载,由于它每每限制的是单个TCP的下载,固然在线上环境不是说可让用户开启无限多个线程,仍是会有限制的,会限制你当前IP的最大TCP。这种状况下下载的上限每每是你的用户最大速度。按照上面的例子,若是你开10个线程已经达到了最大速度,由于再大,你的入口已经被限制死了,那么各个线程之间就会抢占速度,再多开线程也没有用了。
因为 Node 我暂时没有找到比较简单地控制下载速度的方法,所以我就引入了 Nginx。
咱们将每一个TCP链接的速度控制在 1M/s。
加入配置 limit_rate 1M;
准备工做
1.nginx_conf
server { listen 80; server_name limit.qiufeng.com; access_log /opt/logs/wwwlogs/limitqiufeng.access.log; error_log /opt/logs/wwwlogs/limitqiufeng.error.log; add_header Cache-Control max-age=60; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods 'GET, OPTIONS'; add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range'; if ($request_method = 'OPTIONS') { return 204; } limit_rate 1M; location / { root 你的静态目录; index index.html; } }
2.配置本地 host
`127.0.0.1 limit.qiufeng.com`
查看效果,这下基本上速度已是正常了,多线程下载比单线程快了速度。基本是 5-6 : 1 的速度,可是发现若是下载过程当中快速点击数次后,使用Range
下载会愈来愈快(此处怀疑是 Nginx 作了什么缓存,暂时没有深刻研究)。
修改代码中的下载地址 const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg'; 变成 const url = 'http://limit.qiufeng.com/360_0388.jpg';
测试下载速度
还记得上面说的吗,关于 HTTP/1.1
同一站点只能并发 6 个请求,多余的请求会放到下一个批次。可是 HTTP/2.0
不受这个限制,多路复用代替了 HTTP/1.x
的序列和阻塞机制。让咱们来升级 HTTP/2.0
来测试一下。
须要本地生成一个证书。(生成证书方法: https://juejin.im/post/6844903556722475021)
server { listen 443 ssl http2; ssl on; ssl_certificate /usr/local/openresty/nginx/conf/ssl/server.crt; ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/server.key; ssl_session_cache shared:le_nginx_SSL:1m; ssl_session_timeout 1440m; ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers RC4:HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; server_name limit.qiufeng.com; access_log /opt/logs/wwwlogs/limitqiufeng2.access.log; error_log /opt/logs/wwwlogs/limitqiufeng2.error.log; add_header Cache-Control max-age=60; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods 'GET, OPTIONS'; add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range'; if ($request_method = 'OPTIONS') { return 204; } limit_rate 1M; location / { root 你存放项目的前缀路径/node-demo/file-download/; index index.html; } }
10个线程
`将单个下载大小进行修改 const m = 1024 * 400;`
12个线程
24个线程
固然线程不是越多越好,通过测试,发现线程达到必定数量的时候,反而速度会更加缓慢。如下是 36个并发请求的效果图。
那么多进程下载到底有啥用呢?没错,开头也说了,这个分片机制是迅雷等下载软件的核心机制。
咱们打开控制台,很容易地发现这个下载 url,直接一个裸奔的 mp4 下载地址。
把咱们的测试脚本从控制台输入进行。
// 测试脚本,因为太长了,并且若是仔细看了上面的文章也应该能写出代码。实在写不出能够看如下代码。 https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js
直接下载
多线程下载
能够看到因为网易云课堂对单个TCP的下载速度并无什么限制没有那么严格,提高的速度不是那么明显。
咱们就来测试一下网页版的百度云。
以一个 16.6M的文件为例。
打开网页版百度云盘的界面,点击下载
这个时候点击暂停, 打开 chrome -> 更多 -> 下载内容 -> 右键复制下载连接
依旧用上述的网易云课程下载课程的脚本。只不过你须要改一下参数。
`url 改为对应百度云下载连接 m 改为 1024 * 1024 * 2 合适的分片大小~`
直接下载
百度云多单个TCP链接的限速,真的是惨无人道,足足花了217秒!!!就一个17M的文件,平时咱们饱受了它多少的折磨。(除了VIP玩家)
多线程下载
因为是HTTP/1.1 所以咱们只要开启6个以及以上的线程下载就行了。如下是多线程下载的速度,约用时 46 秒。
咱们经过这个图再来切身感觉一下速度差别。
真香,免费且只靠咱们前端本身实现了这个功能,太tm香了,你还不赶忙来试试??
因为 blob
在 各大浏览器有上限大小的限制,所以该方法仍是存在必定的缺陷。
通常状况下都会有限制,那么这个时候就看用户的宽度速度了。
文章写的比较仓促,表达可能不是特别精准,若有错误之处,欢迎各位大佬指出。
回头调研下,有没有网页版百度云加速的插件,若是没有就造一个网页版百度云下载的插件~。
Nginx带宽控制 : https://blog.huoding.com/2015/03/20/423
openresty 部署 https 并开启 http2 支持 : https://www.gryen.com/articles/show/5.html
聊一聊HTTP的Range : https://dabing1022.github.io/2016/12/24/聊一聊HTTP的Range, Content-Range/
若是个人文章有帮助到你,但愿你也能帮助我,欢迎关注个人微信公众号 秋风的笔记
,回复好友
二次,可加微信而且加入交流群,秋风的笔记
将一直陪伴你的左右。