大文件上传-细节图解

2,330 阅读9分钟

前言

前端提交表单数据的业务场景中,大文件上传是一个颇为神秘的功能(大神可以忽略),作者有幸前段时间做过一个关于大文件上传的项目,了解了其实现原理,跟大家分享一下!

image.png

话不多说,直接上干货!本文图比较多,很容易理解!希望大家看完以后能对大文件上传有个系统的认知!减少跳坑!

原理

大文件上传

首先我们来了解一下大文件上传的基本原理,后面分享一些实现细节:

先来看一张图:

实现大文件上传需要前后端配合:

1、前端需要将上传的大文件,通过Blob.prototype.slice接口切片,然后通过http接口请求,逐个将切片发送给服务端

2、服务端接收到切片以后,将切片合并成完整的文件。 image.png

整个大文件上传的原理十分简单,就是分片传输,已减轻服务器的压力。

在了解了大文件的原理以后,我们还需要知道断点续传和妙传的概念,不然你很难了解大文件上传的优势,首先我们来了解一下断点续传:

断点续传

同样是先来看一张图:

1、假设我们在上传文件分片的过程当中,出现了网络异常,服务端只接受到了我们传过去的4个分片,但实际上我们一共有7个文件分片,这时服务端会暂停合并文件,把文件存储起来,这时我们距离能合并文件,还差三个文件。 image.png

2、现在我们重新上传这个文件,依然是切片上传,但是问题来了,我们还需要全部上传7个分片吗?显然不需要,因为服务端已经存储4个分片文件了,我们只需要上传3个上次未成功上传的分片文件即可。

image.png

3、我们想知道已经上传了哪些分片文件,就必须在上传分片文件之前,先发送一个查询请求,请求服务端告诉我们他已经接收到哪些分片了,然后在上传分片的时候剔除已经上传的分片即可,这样就可以实现断点续传,节省性能!

秒传

秒传的原理与断点续传的原理很相似,在查询已上传分片时,服务端会先查询该文件是否已经上传完成,如果上传完成,就直接返回文件链接,而此时,前端就可以不必上传分片,实现秒传,节省性能!

image.png

实现细节

到这里我们知道了大文件秒传和断点续传的实现原理,也知道了他们是怎么优化上传性能的。但是需要思考一些问题:

身份征-md5

我们在查询秒传和断点续传结果(已上传分片)时,需要携带哪些参数才能查到结果呢?怎么判断是同一个文件呢?**

我们需要给文件一个唯一的身份证!

image.png

下图是实际项目中查询接口携带的请求参数,其中的fileMd5就是用来做断点续传和秒传使用的,他就像是文件的身份证,也就是经常说的文件md5值,当服务端接收到这样它以后会将其和文件的地址(如果已经上传成功)和已上传的分片联系起来,下次如果接收到新的文件,会先根据他判断是否符合秒传和断点续传的条件!

image.png

那该怎么生成文件的md5值呢?一般需要根据文件的内容生产,因为要保证同一个文件生成的md5值相同,这样才能保证秒传和断点续传的功能,不需要你自己去实现,可以去网上找已经成熟的库,推荐两个:

1、spark-md5

2、crypto-js

并发传输

2、假如我将文件切成了100个分片,是否可以一次性并发100个请求上传分片?会不会有性能问题?

在上传文件切片时,需要控制并发请求数量(js是单线程执行的,但是http请求是并发的),以免对浏览器造成性能负担!这也是大文件上传的一个核心内容!

老规矩,看图说话,假设最优的并发分片上传数量为6个,那么我们在同一时间发出6个http请求,当请求成功返回后,我们通知文件分片仓库补充发一个请求,就是通过这样依次补充的方式,实现并发请求数量的控制! image.png

现在我们根据这个思路,来具体实现一下,代码如下:

// 模拟一个代码仓库,假设有16个文件分片
const chunks = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
// 假设文件并发上传数量为6
const maxRequest = 6
// 记录分片的总数
let count = chunks.length
// 记录当前请求的个数
let request = 0
// 当前要上传的分片
let nowChunk = maxRequest - 1

// 模拟分片上传
function uploadChunk(e) {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log('上传文件分片:chunk' + e)
            resolve()
        }, 1000)
    })
}

// 主流程
new Promise((resolve) => {
    // 递归函数,实现循环通知和补充分片请求
    function fetch(e) {
        // 每发一个请求, request+1,request不会超过6
        request++
        uploadChunk(e).then(() => {
            // 每完成一个请求,request-1
            request--

            if(count > ++nowChunk) {
                // 每完成一个请求判断当前仓库是否还有未上传的分片,如果有通过递归发送一个新请求
                return fetch(chunks[nowChunk])
            }
            // 当前请求数如果小于等于0,说明所有分片上传完毕,将结果resolve出去
            if(request <= 0) {
                resolve()
            }
        })
    }
    // 并发发送6个http请求
    chunks.slice(0, maxRequest).forEach(fetch);
}).then(() => {
    console.log('分片上传结束!')
})

整个并发控制的流程已经通过上吗demo的代码注释描述的很清楚了,大家可以结合原理图,仔细体会!

总体流程

3、上面的过程完整吗?

细心的朋友可能已经发现,上面的步骤其实是不完整的,在分片上传成功以后,还需要一个请求,获取文件的链接。

image.png

通过回答上面三个问题,我们可以将文件上传分为三个步骤:

1、初始化-生成md5值,进行秒传和断点续传的查询

2、切片上传

3、请求合并后的文件链接

知识点

1、文件上传专属请求头设置content-type:multipart/form-data

我们知道上传文件时,需要将请求头的content-type的值设置为multipart/form-data,用以支持向服务器发送二进制数据。那这是为什么呢?我们先来了解一下multipart/form-data的历史吧!

image.png multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》文档提出。

先来介绍下RFC:请求意见稿(英语:Request for Comments,缩写:RFC)RFC始于1969年,由当时就读加州大学洛杉矶分校(UCLA)的斯蒂芬·克罗克(Stephen D. Crocker)用来记录有关ARPANET开发的非正式文档,他是第一份RFC文档的撰写者。最终演变为用来记录互联网规范、协议、过程等的标准文件。基本的互联网通信协议都有在RFC文件内详细说明。RFC文件还额外加入许多的论题在标准内,例如对于互联网新开发的协议及发展中所有的记录。

总结一下:RFC是用来记录互联网规范、协议、过程等的标准文件。

1867 RFC文档简介中说明文件上传作为一种常见的需求,在目前(1995年)的html中的form表单格式中还不支持,因此提出了一种兼容此需求的mime type。

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

但1867文档中对 multipart/form-data 的具体格式并没有写的非常详细,只在第六部分的【Examples】当中给了一个很基本的范例,所以 1998 年又有了一份新的《RFC 2388:Returning Values from Forms: multipart/form-data 》,详细介绍了multipart/form-data规范。

下面我们来看一下它的规范:

Content-Type: multipart/form-data; boundary=**********

前半部分代表数据类型,而boundary代表分隔符,boundary对应的**********是由请求方自定义设置的。

**********\
Content-Disposition: form-data; name="a"\

**********\
Content-Disposition: form-data; name="b"\

这个FormData类型的数据可以理解为有两个key,分别是foo和b,他们的值用boundary分隔,这里就是**********。

下面我们通过实例来看一下:

image.png image.png

可以看例子中的分隔符(boundary)和上传参数的格式,与规范完全一致!

了解了multipart/form-data以后,我们再来了解一下FormData接口:FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。

通俗的说,FormData就是用来上传表单数据的,使用它浏览器会自动将请求的content-type转换为multipart/form-data,用以上传二进制文件!

但是有一点需要注意:我们不需要设置Content-Type,因为浏览器会自动设置。如果我们手动设置了Content-type,会覆盖掉浏览器自己的设置,而因为我们不知道formData对象里面的boundary分隔符是什么,所以就会导致后端接受到数据以后在Content-type中找不到boundary或者boundary的值与formData中的boundary不一致,导致无法获取正确的数据。

兼容性

浏览器的内部实现不一致,我们的业务场景是上传游戏包,使用input标签,发现火狐浏览器对游戏包类型的判断会有问题,谷歌浏览器表现正常!如有相同业务场景使用需谨慎!

总结

大文件上传的原理其实很简单,希望本文对大家能有所帮助!共同进步,哈哈哈!

参考文档

1、juejin.cn/post/686418…

2、juejin.cn/post/684490…