大文件分片并发下载

1,985 阅读6分钟

前后端分离以后,对于纯前端来说后端的就是个黑盒,尤其文件的分段下载,我们的大脑在想他到底是咋实现的?如何一个视频,下载多少播放多少的?面试的时候也是说的云里雾里,面试官听的也是云里雾里,算了,不琢磨了,自己实现一下看看不就知道了么,是不是?

(声明:本人的测试代码很粗鄙,我就要用简单粗暴的东西,把原来看着很难得东西展示给大家,降低大家得学习成本,如果你们喜欢学习高大上的代码,请移步就好!因为本人深知高大上的弊端就是看不懂,浪费时间!)

第一步,初始化一个项目,并且简单实现前后端分离

1.创建一个文件夹,进去以后执行 npm init -y, 在项目里建立两个文件夹:server,src,server写node,src写前端。目的就是自己启个服务自己请求,自己用,行不行?

image.png

image.png

此时你开启了本地的前端和后端,不信就访问看看

image.png

简单吗?用最简单粗暴的代码实现了前后端分离。最好自己动手试试看,在看下面的代码。

第二步,单片下载

能不能基于上次下载的地方接着下载,也就是断点续传呢?可以的,HTTP 里有这部分协议,就是 range 相关的 header。就是说你可以通过 Range 的 header 告诉服务端下载哪一部分内容。

比如:Range: bytes=200-1000 下载200-1000 字节的内容, 之后它就会返回 206 的状态码,等待下次继续下载。直到出现Range: bytes=200- 表示下载结束了,一般情况下我们会这样设置:

Range: bytes=200-1000, 2000-6576, 19000-

测试如下:我们只下载 test.txt 文件的前5个字节,如下

image.png

image.png

image.png

不想下载axios就用这个:www.unpkg.com/axios@1.3.5… 都可以的,但是我劝大家下载下,因为大多数时候,我们都是在react,vue的框架下面编码的,而且 webpack这样的打包工具帮我们自动处理了文件的引用,导致很多前端编码五六年,离开框架和打包工具,都不知道如何写前端代码了,这就是个弊端。

道理就不讲了,测试看看:

image.png

这次并没有把整个test.txt 文件下载下来,而是下载了5个字节,对不对?

image.png

我们下载5以后的所有字符看看

image.png

查看结果,下图对应上面三个请求

image.png

image.png

image.png

第三步,多片下载

试试:Range 多段设置 Range: 'bytes=0-4,5-20,21-',刷新页面,居然报错了,是个跨域错误。

image.png

刚才都没有报错,现在居然报错了?为啥?什么鬼啊,头顶上方飘出十万个草泥马在奔跑。

烦死了,最讨厌找各种莫名其妙无法理解的错误,刚才3002的端口也跨域了,怎么没有说我不能跨域?现在报,我咋知道咋办?设置下CORS?

想想一下为啥之前不报跨域问题。现在报了吧。原因如下:一般情况下,如果非同源地址发送请求的时候,有个预请求过程,预检请求是 options 请求。一般浏览器会在三种情况下发送预检(preflight)请求:

·用到了非 GET、POST 的请求方法,比如 PUT、DELETE 等,会发预检请求看看服务端是否支持

·用到了一些非常规请求头,比如用到了 Content-Type,会发预检请求看看服务端是否支持

·用到了自定义 header,会发预检请求

上面的三种情况也不符合我们的状况呀,我们用的是 Range: 'bytes=0-4, 5-20, 20-',仔细研究你发现多Range的时候,浏览器把我们的Content-Type改变了,原来默认是:text/plain; charset=UTF-8 , 后面变成了:multipart/byteranges,所以就有了跨域提示,那就cors支持下:

app.options('/', (req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', 'Range') res.end(''); });

看了下express的文档,发现他居然不支持多段下载。什么鬼!

他觉得你可以通过多发几个请求获得,没有必要搞个多段的,无语。。。。。。。。。。。。。。。

那就研究下分片下载,我们重头戏来了

用 range 来实现下文件的分片下载,最终合并成一个文件的功能。是不是就是平常我们大文件的下载模式?

下载个美女图片看看

image.png

image.png

image.png

启动index.html以后,点击按钮你就发现它请求了两次,第一次居然加载了一般图片

image.png

别急你离看到美女已经很近了,接下来我们要做的事将分好的两块图片合在一起,显示出来,不就成一个图片了么,我们可以用 Promise.all 来并行请求,但是如果想要拼接的话,响应值应该是二进制结果才行是不是,不然你请求到刚才的半个图片,怎么合并?

responseType: 'arraybuffer'

这个响应配置出来就是个二进制结果,试试看

image.png

image.png

行不行?接下来他们怎么合并呀?每个 arraybuffer 都创建一个对应的 Uint8Array 对象,我们是不是可以创建一个很大的 Uint8Array对象,然后把他们俩合并到这个大对象里面去,是不是就可以拼接成功,出来一个大美女了?

image.png

image.png

代码如下:

image.png

解释下,就是发送2次请求,按照reponseType为arraybuffer的格式返回数据,返回的数据是一个对象,它里面包含 Uint8Array格式的数据,所以我们用获取它就好了,其他数据不要了。现在获取到了2个Uint8Array数组,按照这2个Uint8Array的大小之和创建一个大的空Uint8Array数组,然后用set方法,把他们塞进去,一张大图片就搞好了。美女来袭!

第四步 优化

再粗鄙的测试代码,主要目标是为了在正式开发的时候能用到它,是不?咱们使用的时候,可以各自优化。比如这样:

index.html:

<!DOCTYPE html>
 <html lang="en">
 <head>
     <script src="https://www.unpkg.com/axios@1.3.5/dist/axios.min.js"></script>
 </head>
 <body>
     <img id="img"/>
     <script>
         async function concurrencyDownload(path, size, chunkSize) {
             let chunkNum = Math.ceil(size / chunkSize);
 
             const downloadTask = [];
             for(let i = 1; i <= chunkNum; i++) {
                 const rangeStart = chunkSize * (i - 1);
                 const rangeEnd = chunkSize * i - 1;
 
                 downloadTask.push(axios.get(path, {
                     headers: {
                         Range: `bytes=${rangeStart}-${rangeEnd}`,
                     },
                     responseType: 'arraybuffer'
                 }))
             }
             const arrayBuffers = await Promise.all(downloadTask.map(task => {
                 return task.then(res => res.data)
             }))
             return mergeArrayBuffer(arrayBuffers);
         }
 
         function  mergeArrayBuffer(arrays) {
             let totalLen = 0;
             for (let arr of arrays) {
                 totalLen += arr.byteLength;
             }
             let res = new Uint8Array(totalLen)
             let offset = 0
             for (let arr of arrays) {
                 let uint8Arr = new Uint8Array(arr)
                 res.set(uint8Arr, offset)
                 offset += arr.byteLength
             }
             return res.buffer
         }
 
         (async function() {
             const { data: len } = await axios.get('http://localhost:3000/length');
             const res = await concurrencyDownload('http://localhost:3000', len, 300000);
             console.log(res)
 
             const blob = new Blob([res]);
             const url = URL.createObjectURL(blob);
             img.src =url;
         })();
 
     </script>
 </body>
 </html>

server/index.js

  const express = require('express');
 const fs = require('fs');
 const app = express();
 
 app.get('/length',(req, res, next) => {
     res.setHeader('Access-Control-Allow-Origin', '*');
     res.end('' + fs.statSync('./guangguang.png').size);
 })
 
 app.options('/', (req, res, next) => {
     res.setHeader('Access-Control-Allow-Origin', '*');
     res.setHeader('Access-Control-Allow-Headers', 'Range')
     res.end('');
 });
 
 app.get('/', (req, res, next) => {
     res.setHeader('Access-Control-Allow-Origin', '*');
     res.download('guangguang.png', {
         acceptRanges: true
     })
 })
 
 app.listen(3000, () => {
     console.log(`server is running at port 3000`)
 })

就是我通过/length的接口拿到这个文件总Range,你想把它分成几个文件就除几,最后得出一个近似相等的片段出来,然后分别获取数据,最后用createObjectURL合并下。

有没有发现,如果我一开始就把第四步的优化代码抛出来,你能明白我在搞啥吗?代码简单易懂,目的是为了从根本上学会它,不是停留在花里胡哨的封装上。