大厂面试官:如何实现大文件上传

2,825 阅读17分钟

前言

小编我这段时间也是在不断的面试,在面试中也是会遇到许多的问题,这不,要是面试官问你如何实现大文件的上传?你怎么回答呢,在我们传统的文件传输过程中,当文件超过一定大小的时候就会导致上传失败,那么,如果遇到需要上传大文件,咱们怎么解决呢?今天小编带你用切片上传从0到1实现大文件的上传,小白也可放心使用。

首先咱们来看下不做任何处理,大文件上传会有什么问题吧,大文件并没有说一定要超过多少M就是大文件,我可以说 100M 就是大文件,也可以说 10M 就是大文件,像我当时做了一个项目有个富文本上传功能,传图片时图片超过了四五M的样子就上传不了了。

直接上传带来的问题

1、耗时久

2、nginx反向代理映射时间超时

3、无法得知上传的进度,并且无法暂停

文件如果是一次性上传,耗时就会比较久,切片带来的好处还有就是你可以得知进度,比如一个文件切成5份,发一份过去就是20%

分片上传

我们可以尝试将文件分片散上传。这样的技术就叫做分片上传。分片上传就是将大文件分成一个个小文件(切片),将切片进行上传,等到后端接收到所有切片,再将切片合并成大文件。通过将大文件拆分成多个小文件进行上传,确实就是解决了大文件上传的问题。因为请求时可以并发执行的,这样的话每个请求时间就会缩短,如果某个请求发送失败,也不需要全部重新发送。

整体思路

前端:

1、读取本地的文件,读成一个文件对象

2、使用slice对文件对象进行切割,并得到Blob类型的文件对象

3、将Blob类型的文件对象转成FormData 表单类型的对象

4、发送请求,将FormData对象切片一个一个的发送给后端

核心就是利用 Blob.prototype.slice这个方法,它和数组的slice方法相似,但不是,文件的slice方法可以返回原文件的某个切片,将大文件对象进行切割得到小的Blob对象,由于后端无法识别Blob对象,所以需要转为前后端都能识别的FormData对象,在用post请求发送给后端

预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间

另外由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序

后端:

1、接受前端传递的切片并解析切片得到数据

2、保存切片到某个文件夹

3、当接受到前端的合并请求后,开始合并切片

4、创建可写流,将所有的切片读成流类型并汇入到可写流中得到完整的文件资源

服务端负责接受前端传输的切片,并在接收到所有切片后合并所有切片

这里又引伸出了两个问题

  1. 何时合并切片,即切片什么时候传输完成
  2. 如何合并切片

第一个问题需要前端配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并。或者也可以额外发一个请求,主动通知服务端进行切片的合并,我这里采用的是第二种

第二个问题,具体如何合并切片呢?这里可以使用 Nodejs 的 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里

话不多说,咱们开干!

正文

具体实现

前端html, 后端node

前端

前端要实现的就是点击按钮可以上传文件

上传控件

咱们就写一个 input 框,input 框的 type 改成 file 类型,它可以自动拿到本地的文件,在写一个button上传按钮。想要清楚他拿到的文件长什么样子我们需要获取 input 框的结构,并为它监听绑定点击事件。

<body>
    <input type="file" id="change">
    <button>上传</button>
    <script>
        const input=document.getElementById('change')
        
        input.addEventListener('change',handleFileChange)

        let fileObj=null//本地读取到的文件资源
        //读取本地文件
        function handleFileChange(e){
            console.log(e.target.files,'1111')
            const [file]=e.target.files
            fileObj=file;
        }
    </script>
</body>

咱们再把一个音频文件放在和前端client文件夹下,可以看他它有19.7M

image-20240826202313007

这个文件就在事件参数中,e.target.files[0]

image-20240826203213291

展开来看里面的信息,里面有个 lastModified,协商缓存中提到过这个字段,表示的是上一次文件修改的时间,里面还有文件的大小 20712969 的 size 大小,单位是字节 Byte ,除两次 1024 就是 M 了,我这里是 19.7M。

其实我们将文件传输给后端,就是传的这个file对象,只不过他会被读成buffer流的形式再计算机中进行传输。所以我们看不到。

这里我们可以将这个对象解构出来,从数组里面解构出一个对象,将这个file对象存起来,拿到文件后进行切片,也就是点击上传时触发这个函数,那就再写一个点击事件 handleUpload

//读取本地文件
        function handleFileChange(e){
            console.log(e.target.files,'1111')
            const [file]=e.target.files
            fileObj=file;
        }
        function handleUpload(){
            if(!fileObj){
                alert('请选择文件')
                return
            }
            const chunkList=createChunk(fileObj)
            console.log(chunkList)
        }

切片函数 createChunk 咱们再拿出来写

//切片
        function createChunk(file,size=5*1024*1024){
            const chunkList=[]
            let cur=0
            while(cur<file.size){
                //slice切
                chunkList.push({
                    file:file.slice(cur,cur+size),
                    chunkIndex:cur,
                    chunkSize:size,
                    fileName:file.name
                })
                cur+=size
            }
            return chunkList
        }

这个函数才是切片的精髓!函数第二个参数表示默认切片大小为 5M , chunkList 用于存放切片, cur 是切的进度, while 循环切,当切完时 cur = file.size 跳出循环

注意这里, file.slice ???, slice 不是数组和字符串身上的方法吗, file 是个对象啊,别急,我们不防看看这个 file 的原型

image-20240826204619761

文件的原型是一个 Blob 对象,我们再展开 Blob

image-20240826204718325

原来 slice 是 Blob 身上的啊,咱们来看看 cdn 上的 blob 介绍

Blob

cdn介绍:Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

当我们选中一个文件时,浏览器默认会帮我们将文件转成一个 Blob 对象, Blob 上自带一个 slice 方法,Blob.slice()接受的参数不是下标,而是起始字节,最终字节

并且 slice 最终返回一个Blob对象

好了, 19.7M 的文件按照 5M 来切就是 4份,最后一份是 4.7 M,打印看看这个 chunkList 数组

image-20240826205350674

nice!

接下来的逻辑就是拿到朝着后端发请求了,把切片都给过去

上传切片

这里用 axios 发请求,自行 cdn 引入

如果想要实现进度条,就自行封装 axios ,请求函数 requestUpload ,请求方法写死 post ,并且axios 人家支持传入一个 onUploadProgress 函数用于计算上传进度

const requestUpload = ({ url, method = 'post', data, headers = {}, onUploadProgress = (e) => e }) => {
    return new Promise((resolve, reject) => {
        // axios支持在请求中传入一个回调onUploadProgress,其目的就是为了知道请求的进度
        axios[method](url, data, { headers, onUploadProgress })
            .then(res => {
                resolve(res)
            })
            .catch(err => {
                reject(err)
            })
    })
}

发请求之前其实还需要对 chunkList 进行一个处理,刚才打印 chunkList 时,里面的每一个切片仅仅只有大小信息,没有其他参数,后端是需要其他信息的,因为网络原因,切片不可能是按照顺序接收的,这里我给 chunkList 再加上下标,还有文件名,切片名,如下

 function handleUpload(){
            if(!fileObj){
                alert('请选择文件')
                return
            }
            const chunkList=createChunk(fileObj)
            console.log(chunkList)
            const chunks=chunkList.map(({file},index)=>{
                return {
                    file,
                    size:file.size,
                    percent:0,
                    chunkName:`${fileObj.name}-${index}`,
                    fileName:fileObj.name,
                    index
                }
            })
            //发请求
            //放在请求体里面
            uploadChunks(chunks)
        }

chunkList 的每一项都是个对象,里面的 file 才是我们需要的,因此进行解构

fileObj 里面是有 name 属性的,就是文件名

chunks 就是封装好的切片,这个切片比 chunkList多了其他后端需要的信息, chunks 被 map 赋值后就直接发请求, uploadChunks 咱们待会儿来写

我们先打印看下切片是否如预期所示,有这些信息

image-20240826211213283

nice!

好了,现在实现函数 uploadChunks 来发请求

发送请求

发请求并不是直接将封装好的切片数组 chunks 交给后端,因为后端并不认识这个对象格式,我们需要先将其转换成数据流。

刚才的 Blob 在 介绍中就说到了, Blob 可以按二进制的格式进行读取,也可以用ReadableStream数据操作

这里我用原生 js 的表单数据流来传递,因此将其转成表单格式的数据流,它是二进制的

function uploadChunks(chunks){
            //console.log(chunks,'2222')//这个数组中的元素是对象,每个对象中有blob类型的文件对象
            const formChunks=chunks.map(({file,chunkName,fileName,index})=>{
                //对象需要转成二进制数据流传输
           const formData=new FormData()//创建表单格式的数据流
           //将切片转换成了表单的数据流
                formData.append('file',file)
                formData.append('chunkName',chunkName)
                formData.append('fileName',fileName)
                return {formData,index}
            })
            //console.log(formChunks,'3333')//后端能识别的了的类型
            }

formChunks 只拿封装好的切片数组中的重要信息 file , fileName ,index ,chunkName ,并且在 map 中创建一个二进制表单数据流,将这些信息挂到 formData 中,最终赋值给 formChunks

之前封装好的 chunks 被转换成了 formChunks,这个切片数组是表单数据格式,来看看长啥样

image-20240826212258469

啊哈!竟然是个空的对象,其实也不难理解,其实这就是二进制数据,浏览器不会给咱们看1的

这里有的小伙伴可能就要问了为何要转表单格式?

答: Blob 是 js 独有的,虽是文件类型,但是不便用于传输,后端那么多语言不一定有 Blob 。但是表单格式前后端都有,其实最早前后端传输就是这个表单格式

好了,把格式问题弄好后,现在对每一个 form 表单格式的切片进行发请求,依旧用 map 遍历,每一个表单切片都进行调用方才封装好的请求函数 requestUpload ,这个函数的里面有个进度条回调函数,我也拿出来写下

function uploadChunks(chunks) {
        //console.log(chunks,'3333')//这个数组中的元素是对象,每个对象中有blob类型的文件对象
        const formChunks = chunks.map(
          ({ file, chunkName, fileName, index }) => {
            const formData = new FormData();
            formData.append("file", file);
            formData.append("chunkName", chunkName);
            formData.append("fileName", fileName);
            return { formData, index };
          }
        );
        //console.log(formChunks,'3333')//后端能识别的了的类型
        const requestList = formChunks.map(({ formData, index }) => {
         return requestUpload({
            url: 'http://localhost:3000/upload',
            data: formData,
            onUploadProgress: createProgress(chunks[index]) // 进度条函数拿出来写
        })
        });
        }

待会后端的路由就写upload路径

之前的 chunks 已经准备好了 percent ,createProgress 函数就是用于更改这个 percent 的,拿出来写

const createProgress = (item) => {
    return (e) => {
        // 为何函数需要return出来,因为axios的onUploadProgress就是个函数体
        // 并且这个函数体参数e就是进度
        item.percent = parseInt(String(e.loaded / e.total) * 100) // axios提供的
    }
}

axios 提供好了写法,直接用即可

好了,目前前端先写到这里,现在转入后端,其实前端还有接口要写,待会儿再来补充

后端

后端要实现的是拿到前端传过来切片进行合并

后端需要先安装依赖,咱们先npm init -y一下

先简单写下,把http服务跑起来

const http = require('http')

const server = http.createServer((req, res) => {
    if (req.url === '/upload') {
        res.end('hello world')
    }
})

server.listen(3000, () => {
    console.log('listening on port 3000');
})

这里后端是拿不到数据的,还没有解决跨域的问题呢,,前端用 live server 跑在 5501 端口,后端在 3000 端口

解决跨域

这里咱们用cors解决

// 解决跨域
res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域 

需要设置两个,一个 请求源,一个 请求头

预检请求

为了保证跨域请求的安全性,比如 csrf 攻击,这里再写个预检请求。跨域请求时,浏览器默认会发一种 options 请求,用于向服务端请求许可,以确定实际请求是否安全,通过预检请求,服务端可以检查跨域请求的来源,请求的方法,请求的头部等信息,再来决定是否允许请求

// 请求预检
if (req.method === 'OPTIONS') {
    res.status = 200
    res.end()
    return 
}

拿到切片

前端请求的方法是 post ,原生 node 想要拿到 post 请求需要用上中间件 body-parser 进行解析,其实这里我们也不需要这个 body-parser ,因为前端传过来的请求数据不是正常的对象,而是表单数据

前端将数据处理成表单数据后发给传给后端,请求头中自动会多出一个Content-Type: multipart/form-data;字段,目的就是告诉后端此时你要接收的数据是表单格式

image-20240827203927134

后端想要解析这个格式,需要npm i multiparty,调用 parse 函数,直接把请求体 req 丢给它,它自动帮你解析

多方 - NPM --- multiparty - npm (npmjs.com)

如果直接拿 req.request 你是拿不到的,是 undefined ,毕竟是 二进制 数据

if (req.url === '/upload') {
    // 接收前端传过来的formData 
    // req.on('data', (data) => {
    //   console.log(data);
    // })
    const form = new multiparty.Form();
    form.parse(req,(err,fields,files)=>{
        console.log(fields);//切片的描述
        console.log(files);//切片的二进制资源被处理成对象
        const [file]=files.file;
        const [fileName]=fields.fileName;
        const [chunkName]=fields.chunkName;
        //保存切片
        const chunkDir =path.resolve(UPLOAD_DIR,`${fileName}-chunks`)
        if(!fse.existsSync(chunkDir)){//该路径是否有效
            fse.mkdirSync(chunkDir)
        }
        //存入切片
       fse.moveSync(file.path,`${chunkDir}/${chunkName}`)
       res.end(JSON.stringify({
        code:0,
        message:'切片上传成功'
       }))
    })

  }

fields 和 files 就是被解析出来的数据, fields 是文件名和切片名,files 是切片的详细数据,咱们打印下看看

image-20240827204030243

看到没有,切片顺序是乱的,因此待会儿不能直接合并

咱们先把切片,文件名,切片名都拿到就是上面的这段代码

const file = files.file[0] 
const fileName = fields.fileName[0]
const chunkName = fields.chunkName[0]

存入切片

这里我存到 server 文件目录下,方便演示

咱们先提前准备一个路径UPLOAD_DIR用于存放切片

const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks') // 准备好地址用来存切片

resolve 的作用是将路径进行合并, __dirname 是当前文件的绝对路径,.是下一级,文件夹就叫 chunks

需要提前引入 path 模块

创建文件夹用fse模块,这个模块是fs模块的加强版

fs-extra - npm (npmjs.com)

fse 模块是 fs 模块的扩展,它增加了异步递归的操作、 Promise 支持以及额外的方法

先要判断UPLOAD_DIR切片目录是否存在,不存在则创建这个文件夹

const chunkDir =path.resolve(UPLOAD_DIR,`${fileName}-chunks`)
        if(!fse.existsSync(chunkDir)){//该路径是否有效
            fse.mkdirSync(chunkDir)
        }

现在可以运行试试,前端请求是否真实给我们创建一个 qiepian 用于存放切片的目录

image-20240827204841958

现在文件目录已经创建好了,接下来就是将切片写入这个目录下

刚才打印的 files ,里面有个 path,细心的小盆友应该发现了,前端传入的切片,先是被默认放入到 C盘 的 temp 目录下,这是操作系统给我们做的,我们现在需要将存放在 temp 下的切片挪到这个 qiepian 中

fse.moveSync(file.path,`${chunkDir}/${chunkName}`)

现在再试试看,是否帮我们把每个切片的目录给生成好

image-20240827205144069

nice!和我们刚刚预想的一样,四个切片都拿到了,我这是一个19.7M的视频,按照5M来切割,会切成四个切片,正好都拿到了!

切片已经从 C盘 挪到我 qiepian 中了,接下来要干的就是合并切片,合并之前一定要将顺序捋正来,从刚刚的打印就可以看出,切片的顺序是乱的,不过我们已经处理好了,因为切片名 chunkName 最后是有个下标的,这个下标和左边的一部分被-分隔开,因此我们可以用 split 将字符串分割成数组,传入-就是取到数组第二项,就是下标

合并切片

后端什么时候合并切片呢?

这里有几个方案可以实现

  1. 后端监听上传请求,当所有的请求都上传完毕时,出发合并请求操作
  2. 前端发完切片后,最后发一个合并请求
  3. 后端设置一个预期切片数量,达标后合并切片

普遍方案都是第二个,前端发完切片后发一个合并请求,开干!这里有一个面试官问我为什么选择第二种,我当时没答上来,不知道有没有明白的小伙伴跟我说说区别,我当时做的时候也就是觉得第二种可能更好更方便就用了这种方法,具体也不知道好在哪,

后端
if (req.url === '/upload') {
	……
} else if (req.url === '/merge') {
	……
}
前端

前端刚才已经发完了所有的切片,继续在下面发一个合并切片的请求 mergeChunks

前端用 map 格式化好 formateList 切片数组后得到的 requestList 就是一个一个的切片请求数组,刚好放入 Promise.all 中实现并发, then 中写入请求函数 mergeChunks ,nice!!

const uploadChunks = () => {
	……
    const requestList = formateList.map(({ formData, index }) => {
	……
	})
	Promise.all(requestList).then(mergeChunks())
}

这也就是切片为何速度更快, Promise.all 实现并发请求

我记得面试官问我就是如果传输的过程中有一个切片被损坏了怎么办?但是也成功传输给了后端,后端拿着这个坏的切片去合成不是会影响原文件吗?我当时沉默了一会,不知道咋回答他,不知道有没有了解的小伙伴可以帮我解答一下。

合并请求 mergeChunks 如下

//合并请求
        function mergeChunks(size=5*1024*1024){
            axios.post('http://localhost:3000/merge',{
                fileName:fileObj.name,
                size
            }).then(
                res=>{
                    console.log(`${fileObj.name}合并完成`)
                }
            )
        }

好了,前后端联调下,若你是前端,你检查网络是很难看到 merge 请求的,因为这相当于是提交表单格式数据,浏览器向服务端发这个请求,页面会重定向刷新的,这需要后端在 merge 路由中看是否有打印

好了,现在前端已经发 merge 请求了,后端需要把前端 post 请求的内容拿到,这里写个函数 resolvePost去解析 post 请求的内容

我将请求体给到 resolvePost ,希望它能解析出 post 参数

function resolvePost(req){
    return new Promise((resolve,reject)=>{
        req.on('data',(data)=>{
            resolve(JSON.parse(data.toString()))
        })
    })
}

监听请求体的 data 事件来获取参数数据,请求接收时,触发 end 事件,最后将 data ,这个 data 是前端 stringify 的 json 对象,需要 parse 回来,因为拿到数据后还需要进行合并,合并是 I/O 操作, node 中的 I/O 是异步宏,这里又是同步,需要 promise 来捋成同步

const {fileName,size}=await resolvePost(req)

前端发合并请求时传过来的就是切片名和切片大小,这里解构出来

记得在 http.createServer 中的回调形参前写 async 关键字

const result=await mergeFileChunk(filePath,fileName,size)

现在实现 mergeFileChunk 函数,这个函数同样需要 return 一个 promise ,这里直接写个 async 关键字,就相当于函数体中 return了一个 promise

既然要合并切片那就需要先读取

async function mergeFileChunk(filePath,fileName,size){
    //拿到所有切片的路径
    const chunkDir=path.resolve(UPLOAD_DIR,`${fileName}-chunks`)
    //拿到所有切片
    let chunksList =  fse.readdirSync(chunkDir)//读取所有文件的文件名
    console.log(chunksList);

readdir 是读取文件夹中的所有文件, readfile是读取文件内容

它会给我们读成一个数组,但是网络情况你也不清楚,因此我们需要将其排序

前面已经提到过,用 split 拿到最后的 index,然后 sort

 chunksList.sort((a,b)=>a.split('-')[1]-b.split('-')[1])//切成数组取下标1

sort 会影响原数组,此时的 chunksList 已经是有序的了

现在进行合并

这个 chunksList 别看打印出来是个数组,里面每个切片是个字符串,其实这是真实的文件资源,有后缀的

一个形象的比喻,这些切片都是冰块,我们需要将其融化成水流,然后才能汇聚在一起

合并之前需要将切片转换成流类型,我再写个函数 pipeStream 将这些切片转成流类型,我们需要告诉这个函数切片的路径以及切片名

const result=chunksList.map((chunkFileName,index)=>{
    const chunkPath=path.resolve(chunkDir,chunkFileName)
        //console.log(chunkPath);
        //!!!!!!合并
        return pipeStream(chunkPath,fse.createWriteStream(filePath,{
            start:index*size,
            end:(index+1)*size//挖一个可写流
        }))

    })

这里使用了 chunksList 数组中的文件名来合并多个文件块到一个完整的文件。这里使用了 map 方法遍历文件名列表,并对每个文件块执行合并操作

合并最终写入的路径写在chinkList中

现在将所有的切片先读到,然后监听 end 事件,把切片都移除掉,读到的流最终汇入到可写流中,也就是第二个参数

function pipeStream(path,writeStream){
    return new Promise(()=>{
        const readStream=fse.createReadStream(path)
        readStream.on('end',()=>{
            fse.unlinkSync(path)//被读取完的切片移除掉
            resolve()
        })
        readStream.pipe(writeStream)
    })
}

最后赋值得到的 arr ,都是一个个的 promise 对象,保证每个切片 resolve 即可 await Promise.all(arr)

最后见证奇迹的时刻到了

image-20240827213248350

在文件资源管理器中查看

image-20240827213356540

可以看到后端文件夹真的就多了这样一个文件,完美!

总结

实现过程

前端拿到整个文件后利用文件 Blob 原型上的 slice 方法进行切割,将得到的切片数组 chunkList 添加一些信息,比如文件名和下标,得到 uploadChunkList ,但是 uploadChunkList 想要传给后端还需要将其转换成表单数据格式,通过 Promise.all 并发发给后端,传输完毕后发送一个合并请求,合并请求带上文件名和切片大小信息

后端拿到前端传过来的表单格式数据需要 multiparty 依赖来解析这个表单数据,然后把切片解析出来去存入切片,存入到提前创建好的目录中,最后将切片按照下标进行排序再来合并切片,合并切片的实现比较复杂,需要创建一个可以写入流的文件,将每个片段读成流类型,再写入到可写流中

切片的优点

  • 将大文件切割成片实现并发传输,可以提高传输速度
  • 可以实现传输进度功能,提高用户体验

好啦,今天的分享就到这里结束了,源代码大家可以到我的gitee仓库取

gitee.com/Luo-zhao-fa…

如何大家觉得有所帮助的话可以点赞收藏一下嘛感谢感谢!