大文件上传问题描述
- 中等文件上传解决文案 – nginx 放行
在我们工作中,上传功能最常见的就是excel的上传功能,一般来说,一个excel的大小在10MB以内
吧,如果有好几十MB的excel,就勉强算是中等文件吧,此时,我们需要设置nginx的client_max_body_size
值,将其放开,只不过一次上传一个几十MB的文件,接口会慢一些,不过
也勉强能够接受。
但是,如果一个文件有几百兆,或者好几个G呢?上述方式就不合适了。
既然一次性上传不行,那么咱们就把大文件拆分开来,进行分批、分堆、分片、一点点上传的操作,等上传完了,再将一片片文件合并一起,再恢复成原来的样子即可
最常见的这个需求,就是视频的上传,比如:腾讯视频创作平台、哔哩哔哩后台等…
-
大文件上传解决 – 文件分片
一共三步即可:
- 第一步,大文件拆分成一片又一片(分片操作)
- 第二步,每一次请求给后端带一片文件(分片上传)
- 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可(合并分片)
文件分片操作大致可分为上述三步骤,但在这三步骤中,还有一些细节需要我们注意,这个后文中会一一说到,我们继续往下阅读
- 大文件上传效果图
为便于更好理解,我们看一下已经做好的效果图
由上述效果图,我们可以看到,一个58MB的大文件,被分成了12片上传,很快啊
!上传完成。
思考两个问题:
- 若某个文件已经存在(曾经上传过),那我还需要上传吗?
- 若同一时刻,两个人都在分片上传完大文件,并发起合并请求,如何才能保证不合并错呢?如A文件分片成a1,a2,a3;B文件分片成b1,b2,b3 。合并操作肯定不能把a1,a2,a3文件内容合并到B文件中去。
解决方案就是:
- 要告知后端我这次上传的文件是哪一个,下次上传的文件又是哪一个
- 就像我们去修改表格中的某条数据时,需要有一个固定的参数id,告知后端去update具体的那一条数据
- 知道具体的文件id,就不会操作错了
那新的问题又来了:
前端如何才能确定文件的id,如何才能得到文件的唯一标识?
如何得到文件的唯一标识
1. 什么是spark-md5?
spark-md5是基于md5的一种优秀的算法,用处很多,其中就可以去计算文件的唯一身份证标识-hash值
- 只要文件内容不同(包含的二进制01不同),那么使用spark-md5这个npm包包,得到的结果hash值就不一样
- 这个独一无二的hash值,就可以看做大文件的id
- 发请求时,就可以将这个大文件的hash值唯一id带着传给后端,后端就知道去操作那个文件了
当然还有别的工具库,如CryptoJS也可以计算文件的hash值,不过spark-md5更主流、更优秀
2. 使用spark-md5 直接计算整个文件的hash 值(唯一id身份证标识)
<input type="file" @change="changeFile"><script>const inputDom = document.querySelector('input') // 获取input文件标签的dom元素inputDom.onchange = (e) => {let file = inputDom.files[0] // 拿到文件let spark = new SparkMD5.ArrayBuffer() // 实例化spark-md5let fileReader = new FileReader() // 实例化文件阅读器fileReader.onload = (e) => {spark.append(e.target.result) // 添加到spark算法中计算let hash = spark.end() // 计算完成得到hash结果console.log('文件的hash值为:', hash);}fileReader.readAsArrayBuffer(file) // 开始阅读这个文件,阅读完成触发onload方法}</script><input type="file" @change="changeFile"> <script> const inputDom = document.querySelector('input') // 获取input文件标签的dom元素 inputDom.onchange = (e) => { let file = inputDom.files[0] // 拿到文件 let spark = new SparkMD5.ArrayBuffer() // 实例化spark-md5 let fileReader = new FileReader() // 实例化文件阅读器 fileReader.onload = (e) => { spark.append(e.target.result) // 添加到spark算法中计算 let hash = spark.end() // 计算完成得到hash结果 console.log('文件的hash值为:', hash); } fileReader.readAsArrayBuffer(file) // 开始阅读这个文件,阅读完成触发onload方法 } </script><input type="file" @change="changeFile"> <script> const inputDom = document.querySelector('input') // 获取input文件标签的dom元素 inputDom.onchange = (e) => { let file = inputDom.files[0] // 拿到文件 let spark = new SparkMD5.ArrayBuffer() // 实例化spark-md5 let fileReader = new FileReader() // 实例化文件阅读器 fileReader.onload = (e) => { spark.append(e.target.result) // 添加到spark算法中计算 let hash = spark.end() // 计算完成得到hash结果 console.log('文件的hash值为:', hash); } fileReader.readAsArrayBuffer(file) // 开始阅读这个文件,阅读完成触发onload方法 } </script>
直接计算一个整文件的hash值,文件小的话,还是比较快的,但是当文件比较大的时,直接计算一整个文件的hash值,就会比较慢了。
此刻大文件分片的好处,再一次体现出来:大文件分片不仅仅可以用于发送请求传递给后端,也可以用于计算大文件的hash值,直接计算一个大文件hash值任务慢,那就拆分成一些小任务,这样效率也提升了不少
至此,又延伸出一个问题,如何给大文件分片?
当我们想解决一个A问题时,我们发现需要进一步,解决其中包含a1问题,当我们想要解决a1问题时,我们发现需要再进一步解决a1的核心a11问题。当a11问题被解决时,a1也就解决了,与此同时A问题也就迎刃而解了
3. 给文件分片操作
-
文件分片,别名文件分堆,又名文件分块,也叫作文件拆分
-
类比,一个大的字符串可以截取slice(切割)成好几个小的字符串
-
同理,一个大文件也可以slice成好多小文件,对应api: file.slice
-
文件file是特殊的二进制blob文件(所以file可以用blob的方法)
-
上代码
const inputDom = document.querySelector('input') // 获取input文件标签的dom元素inputDom.onchange = (e) => {let file = inputDom.files[0] // 拿到文件function sliceFn(file, chunkSize = 1 * 1024 * 1024) {const result = [];// 从第0字节开始切割,一次切割1 * 1024 * 1024字节for (let i = 0; i < file.size; i = i + chunkSize) {result.push(file.slice(i, i + chunkSize));}return result;}const chunks = sliceFn(file)console.log('文件分片成数组', chunks);}const inputDom = document.querySelector('input') // 获取input文件标签的dom元素 inputDom.onchange = (e) => { let file = inputDom.files[0] // 拿到文件 function sliceFn(file, chunkSize = 1 * 1024 * 1024) { const result = []; // 从第0字节开始切割,一次切割1 * 1024 * 1024字节 for (let i = 0; i < file.size; i = i + chunkSize) { result.push(file.slice(i, i + chunkSize)); } return result; } const chunks = sliceFn(file) console.log('文件分片成数组', chunks); }const inputDom = document.querySelector('input') // 获取input文件标签的dom元素 inputDom.onchange = (e) => { let file = inputDom.files[0] // 拿到文件 function sliceFn(file, chunkSize = 1 * 1024 * 1024) { const result = []; // 从第0字节开始切割,一次切割1 * 1024 * 1024字节 for (let i = 0; i < file.size; i = i + chunkSize) { result.push(file.slice(i, i + chunkSize)); } return result; } const chunks = sliceFn(file) console.log('文件分片成数组', chunks); }
文件分片结果效果图(比如我选了一个5兆多的文件去分片):
4. 大文件分片后搭配spark-md5计算整个文件的hash 值
有了上述分好片的chunks数组(数组中存放一片又一片小文件),再结合spark-md5,使用递归的写法,一片一片的再去读取计算,最终算出结果
inputDom.onchange = (e) => {let file = inputDom.files[0]function sliceFn(file, chunkSize = 1 * 1024 * 1024) {const result = [];for (let i = 0; i < file.size; i = i + chunkSize) {result.push(file.slice(i, i + chunkSize));}return result;}const chunks = sliceFn(file)// 分好片的大文件数组,去计算hash。progressFn为进度条函数,需额外定义const hash = await calFileMd5Fn(chunks,progressFn)// "233075d0c65166792195384172387deb" // 32位的字符串}inputDom.onchange = (e) => { let file = inputDom.files[0] function sliceFn(file, chunkSize = 1 * 1024 * 1024) { const result = []; for (let i = 0; i < file.size; i = i + chunkSize) { result.push(file.slice(i, i + chunkSize)); } return result; } const chunks = sliceFn(file) // 分好片的大文件数组,去计算hash。progressFn为进度条函数,需额外定义 const hash = await calFileMd5Fn(chunks,progressFn) // "233075d0c65166792195384172387deb" // 32位的字符串 }inputDom.onchange = (e) => { let file = inputDom.files[0] function sliceFn(file, chunkSize = 1 * 1024 * 1024) { const result = []; for (let i = 0; i < file.size; i = i + chunkSize) { result.push(file.slice(i, i + chunkSize)); } return result; } const chunks = sliceFn(file) // 分好片的大文件数组,去计算hash。progressFn为进度条函数,需额外定义 const hash = await calFileMd5Fn(chunks,progressFn) // "233075d0c65166792195384172387deb" // 32位的字符串 }
/*** chunks:文件分好片的数组、progressCallbackFn回调函数方法,用于告知外界进度的* 因为文件阅读器是异步的,所以要套一层Promise方便拿到异步的计算结果**/function calFileMd5Fn(chunks, progressCallbackFn) {return new Promise((resolve, reject) => {let currentChunk = 0 // 准备从第0块开始读let spark = new SparkMD5.ArrayBuffer() // 实例化SparkMD5用于计算文件hash值let fileReader = new FileReader() // 实例化文件阅读器用于读取blob二进制文件fileReader.onerror = reject // 兜一下错fileReader.onload = (e) => {progressCallbackFn(Math.ceil(currentChunk / chunks.length * 100)) // 抛出一个函数,用于告知进度spark.append(e.target.result) // 将二进制文件追加到spark中(官方方法)currentChunk = currentChunk + 1 // 这个读完就加1,读取下一个blob// 若未读取到最后一块,就继续读取;否则读取完成,Promise带出结果if (currentChunk < chunks.length) {fileReader.readAsArrayBuffer(chunks[currentChunk])} else {resolve(spark.end()) // resolve出去告知结果 spark.end官方api}}// 文件读取器的readAsArrayBuffer方法开始读取文件,从blob数组中的第0项开始fileReader.readAsArrayBuffer(chunks[currentChunk])})}/** * chunks:文件分好片的数组、progressCallbackFn回调函数方法,用于告知外界进度的 * 因为文件阅读器是异步的,所以要套一层Promise方便拿到异步的计算结果 **/ function calFileMd5Fn(chunks, progressCallbackFn) { return new Promise((resolve, reject) => { let currentChunk = 0 // 准备从第0块开始读 let spark = new SparkMD5.ArrayBuffer() // 实例化SparkMD5用于计算文件hash值 let fileReader = new FileReader() // 实例化文件阅读器用于读取blob二进制文件 fileReader.onerror = reject // 兜一下错 fileReader.onload = (e) => { progressCallbackFn(Math.ceil(currentChunk / chunks.length * 100)) // 抛出一个函数,用于告知进度 spark.append(e.target.result) // 将二进制文件追加到spark中(官方方法) currentChunk = currentChunk + 1 // 这个读完就加1,读取下一个blob // 若未读取到最后一块,就继续读取;否则读取完成,Promise带出结果 if (currentChunk < chunks.length) { fileReader.readAsArrayBuffer(chunks[currentChunk]) } else { resolve(spark.end()) // resolve出去告知结果 spark.end官方api } } // 文件读取器的readAsArrayBuffer方法开始读取文件,从blob数组中的第0项开始 fileReader.readAsArrayBuffer(chunks[currentChunk]) }) }/** * chunks:文件分好片的数组、progressCallbackFn回调函数方法,用于告知外界进度的 * 因为文件阅读器是异步的,所以要套一层Promise方便拿到异步的计算结果 **/ function calFileMd5Fn(chunks, progressCallbackFn) { return new Promise((resolve, reject) => { let currentChunk = 0 // 准备从第0块开始读 let spark = new SparkMD5.ArrayBuffer() // 实例化SparkMD5用于计算文件hash值 let fileReader = new FileReader() // 实例化文件阅读器用于读取blob二进制文件 fileReader.onerror = reject // 兜一下错 fileReader.onload = (e) => { progressCallbackFn(Math.ceil(currentChunk / chunks.length * 100)) // 抛出一个函数,用于告知进度 spark.append(e.target.result) // 将二进制文件追加到spark中(官方方法) currentChunk = currentChunk + 1 // 这个读完就加1,读取下一个blob // 若未读取到最后一块,就继续读取;否则读取完成,Promise带出结果 if (currentChunk < chunks.length) { fileReader.readAsArrayBuffer(chunks[currentChunk]) } else { resolve(spark.end()) // resolve出去告知结果 spark.end官方api } } // 文件读取器的readAsArrayBuffer方法开始读取文件,从blob数组中的第0项开始 fileReader.readAsArrayBuffer(chunks[currentChunk]) }) }
至此,我们大文件分片上传操作,已经完成了三分之一了。我们已经完成了大文件的分片和计算大文件的hash值唯一身份证id(实际上,计算大文件的hash值,还是挺耗费时长的,优化方案就是开一个辅助线程进行异步计算操作,不过这个是优化的点,文末会提到)
至此,我们大文件分片上传操作,已经完成了三分之一了。我们已经完成了大文件的分片和计算大文件的hash值唯一身份证id(实际上,计算大文件的hash值,还是挺耗费时长的,优化方案就是开一个辅助线程进行异步计算操作,不过这个是优化的点,文末会提到)
接下来,就到了第二步,发请求环节:将已经分好片的每一片和这个大文件的hash值作为参数传递给后端(当然还有别的参数,比如文件名、文件分了多少片,每次上传的是那一片【索引】等—看后端定义)
大文件上传解决方案:
- 第一步,大文件拆分成一片又一片(分片操作)✔️
- 第二步,每一次请求给后端带一片文件(分片上传)
- 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可
分片上传发请求,一片 一请求
分片上传发请求前 的校验请求
- 大文件分好片以后,在分片文件上传前,先发个请求带着大文件的唯一身份证标识hash值,去问问后端有没有上传过这个文件,或者服务端的这个文件是否上传完整(比如曾经上传一半的时候,突然断网了,或者刷新网页导致上传中断)
- 后端去看看已经操作完成的文件夹中的文件,有没有叫做这个hash的,根据有没有返回不同的状态码
比如,如下状态码:
- 等于0表示没有上传过,直接上传
- 等于1曾经上传过,不需要再上传了(或:障眼法文件秒传递)
- 等于2表示曾经上传过一部分,现在要继续上传
对应前端代码:
以下代码举例是vue3的语法举例,大家知道每一步做什么即可,文章看完,建议大家去笔者的github仓库把前后端代码,都拉下来跑起来,结合代码中的注释,才能够更好的理解
<template><div id="app"><input ref="inputRef" class="inputFile" type="file" @change="changeFile" /><div>大文件 <span class="bigFileC">?</span> 分了{{ chunksCount }}片:</div><div class="pieceItem" v-for="index in chunksCount" :key="index"><span class="a">{{ index - 1 }}</span><span class="b">?</span></div><div>计算此大文件的hash值进度</div><div class="r">结果为: {{ fileHash }}</div><progress max="100" :value="hashProgress"></progress> {{ hashProgress }}%<div><div>上传文件的进度</div><div class="r" v-show="fileProgress == 100">文件上传完成</div><progress max="100" :value="fileProgress"></progress> {{ fileProgress }}%</div></div></template><template> <div id="app"> <input ref="inputRef" class="inputFile" type="file" @change="changeFile" /> <div>大文件 <span class="bigFileC">?</span> 分了{{ chunksCount }}片:</div> <div class="pieceItem" v-for="index in chunksCount" :key="index"> <span class="a">{{ index - 1 }}</span> <span class="b">?</span> </div> <div>计算此大文件的hash值进度</div> <div class="r">结果为: {{ fileHash }}</div> <progress max="100" :value="hashProgress"></progress> {{ hashProgress }}% <div> <div>上传文件的进度</div> <div class="r" v-show="fileProgress == 100">文件上传完成</div> <progress max="100" :value="fileProgress"></progress> {{ fileProgress }}% </div> </div> </template><template> <div id="app"> <input ref="inputRef" class="inputFile" type="file" @change="changeFile" /> <div>大文件 <span class="bigFileC">?</span> 分了{{ chunksCount }}片:</div> <div class="pieceItem" v-for="index in chunksCount" :key="index"> <span class="a">{{ index - 1 }}</span> <span class="b">?</span> </div> <div>计算此大文件的hash值进度</div> <div class="r">结果为: {{ fileHash }}</div> <progress max="100" :value="hashProgress"></progress> {{ hashProgress }}% <div> <div>上传文件的进度</div> <div class="r" v-show="fileProgress == 100">文件上传完成</div> <progress max="100" :value="fileProgress"></progress> {{ fileProgress }}% </div> </div> </template>
发校验请求
/*** 发请求,校验文件是否上传过,分三种情况:见:fileStatus* */export function checkFileFn(fileMd5) {return new Promise((resolve, reject) => {resolve(axios.post(`http://127.0.0.1:8686/bigfile/check?fileMd5=${fileMd5}`))})}const res = await checkFileFn(fileMd5);// res.data.resultCode 为0 或1 或2/** * 发请求,校验文件是否上传过,分三种情况:见:fileStatus * */ export function checkFileFn(fileMd5) { return new Promise((resolve, reject) => { resolve(axios.post(`http://127.0.0.1:8686/bigfile/check?fileMd5=${fileMd5}`)) }) } const res = await checkFileFn(fileMd5); // res.data.resultCode 为0 或1 或2/** * 发请求,校验文件是否上传过,分三种情况:见:fileStatus * */ export function checkFileFn(fileMd5) { return new Promise((resolve, reject) => { resolve(axios.post(`http://127.0.0.1:8686/bigfile/check?fileMd5=${fileMd5}`)) }) } const res = await checkFileFn(fileMd5); // res.data.resultCode 为0 或1 或2
对应后端代码:
笔者后端代码是springboot,文末会附上代码,大家看一下
private String fileStorePath = "F:\kkk";
// 大文件上传操作在F盘下的kkk文件夹中操作
java复制代码/*** @param fileMd5* @Title: 判断文件是否上传过,是否存在分片,断点续传* @MethodName: checkBigFile* @Exception* @Description: 文件已存在,1* 文件没有上传过,0* 文件上传中断过,2 以及现在有的数组分片索引*/@RequestMapping(value = "/check", method = RequestMethod.POST)@ResponseBodypublic JsonResult checkBigFile(String fileMd5) {JsonResult jr = new JsonResult();// 秒传File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5);if (mergeMd5Dir.exists()) {mergeMd5Dir.mkdirs();jr.setResultCode(1);//文件已存在return jr;}// 读取目录里的所有文件File dir = new File(fileStorePath + "/" + fileMd5);File[] childs = dir.listFiles();if (childs == null) {jr.setResultCode(0);//文件没有上传过} else {jr.setResultCode(2);//文件上传中断过,除了状态码为2,还有已上传的文件分片索引List<String> list = Arrays.stream(childs).map(f->f.getName()).collect(Collectors.toList());jr.setResultData(list.toArray());}return jr;}java 复制代码 /** * @param fileMd5 * @Title: 判断文件是否上传过,是否存在分片,断点续传 * @MethodName: checkBigFile * @Exception * @Description: 文件已存在,1 * 文件没有上传过,0 * 文件上传中断过,2 以及现在有的数组分片索引 */ @RequestMapping(value = "/check", method = RequestMethod.POST) @ResponseBody public JsonResult checkBigFile(String fileMd5) { JsonResult jr = new JsonResult(); // 秒传 File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5); if (mergeMd5Dir.exists()) { mergeMd5Dir.mkdirs(); jr.setResultCode(1);//文件已存在 return jr; } // 读取目录里的所有文件 File dir = new File(fileStorePath + "/" + fileMd5); File[] childs = dir.listFiles(); if (childs == null) { jr.setResultCode(0);//文件没有上传过 } else { jr.setResultCode(2);//文件上传中断过,除了状态码为2,还有已上传的文件分片索引 List<String> list = Arrays.stream(childs).map(f->f.getName()).collect(Collectors.toList()); jr.setResultData(list.toArray()); } return jr; }java 复制代码 /** * @param fileMd5 * @Title: 判断文件是否上传过,是否存在分片,断点续传 * @MethodName: checkBigFile * @Exception * @Description: 文件已存在,1 * 文件没有上传过,0 * 文件上传中断过,2 以及现在有的数组分片索引 */ @RequestMapping(value = "/check", method = RequestMethod.POST) @ResponseBody public JsonResult checkBigFile(String fileMd5) { JsonResult jr = new JsonResult(); // 秒传 File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5); if (mergeMd5Dir.exists()) { mergeMd5Dir.mkdirs(); jr.setResultCode(1);//文件已存在 return jr; } // 读取目录里的所有文件 File dir = new File(fileStorePath + "/" + fileMd5); File[] childs = dir.listFiles(); if (childs == null) { jr.setResultCode(0);//文件没有上传过 } else { jr.setResultCode(2);//文件上传中断过,除了状态码为2,还有已上传的文件分片索引 List<String> list = Arrays.stream(childs).map(f->f.getName()).collect(Collectors.toList()); jr.setResultData(list.toArray()); } return jr; }
前端根据接口的状态码,作相应控制,没上传过正常操作,曾经上传过了,就做个提示文件已上传。这里需要特别注意一下,曾经上传中断的情况
特别情况:当前上传的文件曾经中断过(断电续传)
我们来捋一下逻辑就明晰了:
- 假设一个大文件分为了10片,对应文件片的索引是0~9
- 在执行上传的时候,发了10个请求,分别带上对应的索引文件片
- 由于不可抗力因素,导致只上传成功了3片文件,分别是索引0、索引8、索引9
- 还有索引1、2、3、4、5、6、7这七片文件没上传成功
- 那么在检查文件时,后端除了返回状态码2,同时也返回后端已经上传成功的片的索引有哪些
- 即:
{resultCode:2 , resultData:[0,8,9]}
- 我们在执行上传文件操作时候,去掉这三个已经上传完成的即可,上传那些未完成的
js复制代码// 等于2表示曾经上传过一部分,现在要继续上传if (res.data.resultCode == 2) {// 若是文件曾上传过一部分,后端会返回上传过得部分的文件索引,前端通过索引可以知道哪些// 上传过,做一个过滤,已上传的文件就不用继续上传了,上传未上传过的文件片doneFileList = res.data.resultData.map((item) => {return item * 1; // 后端给到的是字符串索引,这里转成数字索引});}js 复制代码 // 等于2表示曾经上传过一部分,现在要继续上传 if (res.data.resultCode == 2) { // 若是文件曾上传过一部分,后端会返回上传过得部分的文件索引,前端通过索引可以知道哪些 // 上传过,做一个过滤,已上传的文件就不用继续上传了,上传未上传过的文件片 doneFileList = res.data.resultData.map((item) => { return item * 1; // 后端给到的是字符串索引,这里转成数字索引 }); }js 复制代码 // 等于2表示曾经上传过一部分,现在要继续上传 if (res.data.resultCode == 2) { // 若是文件曾上传过一部分,后端会返回上传过得部分的文件索引,前端通过索引可以知道哪些 // 上传过,做一个过滤,已上传的文件就不用继续上传了,上传未上传过的文件片 doneFileList = res.data.resultData.map((item) => { return item * 1; // 后端给到的是字符串索引,这里转成数字索引 }); }
doneFileList数组存储的就是后端返回的,曾经上传过一部分的数组分片文件索引
比如下面这两张图,就是文件曾经上传中断以后的,再次上传的检查接口返回的数据
示例图一:
返回的是分片文件的名,也就是分片的索引,如下图:
前端根据doneFileList判断,去准备参数
js复制代码// 说明没有上传过,组装一下,直接使用if (doneFileList.length == 0) {formDataList = chunks.map((item, index) => {// 后端接参大致有:文件片、文件分的片数、每次上传是第几片(索引)、文件名、此完整大文件hash值// 具体后端定义的参数prop属性名,看他们如何定义的,这个无妨...let formData = new FormData();formData.append("file", item); // 使用FormData可以将blob文件转成二进制binaryformData.append("chunks", chunks.length);formData.append("chunk", index);formData.append("name", fileName);formData.append("md5", fileMd5);return { formData };});}// 说明曾经上传过,需要过滤一下,曾经上传过的就不用再上传了else {formDataList = chunks.filter((index) => {return !doneFileList.includes(index);}).map((item, index) => {let formData = new FormData();// 这几个是后端需要的参数formData.append("file", item); // 使用FormData可以将blob文件转成二进制binaryformData.append("chunks", chunks.length);formData.append("chunk", index);formData.append("name", fileName);formData.append("md5", fileMd5);return { formData };});}// 带着分片数组请求参数,和文件名 fileName = file.name// 准备一次并发很多的请求fileUpload(formDataList, fileName);js 复制代码 // 说明没有上传过,组装一下,直接使用 if (doneFileList.length == 0) { formDataList = chunks.map((item, index) => { // 后端接参大致有:文件片、文件分的片数、每次上传是第几片(索引)、文件名、此完整大文件hash值 // 具体后端定义的参数prop属性名,看他们如何定义的,这个无妨... let formData = new FormData(); formData.append("file", item); // 使用FormData可以将blob文件转成二进制binary formData.append("chunks", chunks.length); formData.append("chunk", index); formData.append("name", fileName); formData.append("md5", fileMd5); return { formData }; }); } // 说明曾经上传过,需要过滤一下,曾经上传过的就不用再上传了 else { formDataList = chunks .filter((index) => { return !doneFileList.includes(index); }) .map((item, index) => { let formData = new FormData(); // 这几个是后端需要的参数 formData.append("file", item); // 使用FormData可以将blob文件转成二进制binary formData.append("chunks", chunks.length); formData.append("chunk", index); formData.append("name", fileName); formData.append("md5", fileMd5); return { formData }; }); } // 带着分片数组请求参数,和文件名 fileName = file.name // 准备一次并发很多的请求 fileUpload(formDataList, fileName);js 复制代码 // 说明没有上传过,组装一下,直接使用 if (doneFileList.length == 0) { formDataList = chunks.map((item, index) => { // 后端接参大致有:文件片、文件分的片数、每次上传是第几片(索引)、文件名、此完整大文件hash值 // 具体后端定义的参数prop属性名,看他们如何定义的,这个无妨... let formData = new FormData(); formData.append("file", item); // 使用FormData可以将blob文件转成二进制binary formData.append("chunks", chunks.length); formData.append("chunk", index); formData.append("name", fileName); formData.append("md5", fileMd5); return { formData }; }); } // 说明曾经上传过,需要过滤一下,曾经上传过的就不用再上传了 else { formDataList = chunks .filter((index) => { return !doneFileList.includes(index); }) .map((item, index) => { let formData = new FormData(); // 这几个是后端需要的参数 formData.append("file", item); // 使用FormData可以将blob文件转成二进制binary formData.append("chunks", chunks.length); formData.append("chunk", index); formData.append("name", fileName); formData.append("md5", fileMd5); return { formData }; }); } // 带着分片数组请求参数,和文件名 fileName = file.name // 准备一次并发很多的请求 fileUpload(formDataList, fileName);
上述代码实现了,正常上传以及曾经中断过的文件继续上传,这就是断点续传
上述代码实现了,正常上传以及曾经中断过的文件继续上传,这就是断点续传
上述代码实现了,正常上传以及曾经中断过的文件继续上传,这就是断点续传
使用Promise.allSettled(arr)并发上传分
- 使用Promise.allSettled发请求好一些,挂了的就挂了,不影响后续不挂的分片上传请求
- Promise.all则不行,一个挂了都挂了
前端代码
js复制代码const fileUpload = (formDataList, fileName) => {const requestListFn = formDataList.map(async ({ formData }, index) => {const res = await sliceFileUploadFn(formData);// 每上传完毕一片文件,后端告知已上传了多少片,除以总片数,就是进度fileProgress.value = Math.ceil((res.data.resultData / chunksCount.value) * 100);return res;});// 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求Promise.allSettled(requestListFn).then((many) => {// 都上传完毕了,文件上传进度条就为100%了});};js 复制代码 const fileUpload = (formDataList, fileName) => { const requestListFn = formDataList.map(async ({ formData }, index) => { const res = await sliceFileUploadFn(formData); // 每上传完毕一片文件,后端告知已上传了多少片,除以总片数,就是进度 fileProgress.value = Math.ceil( (res.data.resultData / chunksCount.value) * 100 ); return res; }); // 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求 Promise.allSettled(requestListFn).then((many) => { // 都上传完毕了,文件上传进度条就为100%了 }); };js 复制代码 const fileUpload = (formDataList, fileName) => { const requestListFn = formDataList.map(async ({ formData }, index) => { const res = await sliceFileUploadFn(formData); // 每上传完毕一片文件,后端告知已上传了多少片,除以总片数,就是进度 fileProgress.value = Math.ceil( (res.data.resultData / chunksCount.value) * 100 ); return res; }); // 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求 Promise.allSettled(requestListFn).then((many) => { // 都上传完毕了,文件上传进度条就为100%了 }); };
后端代码
java复制代码/*** 上传文件* @param param* @param request* @return* @throws Exception*/@RequestMapping(value = "/upload", method = RequestMethod.POST)@ResponseBodypublic JsonResult filewebUpload(MultipartFileParam param, HttpServletRequest request) {JsonResult jr = new JsonResult();boolean isMultipart = ServletFileUpload.isMultipartContent(request);// 文件名String fileName = param.getName();// 文件每次分片的下标int chunkIndex = param.getChunk();if (isMultipart) {File file = new File(fileStorePath + "/" + param.getMd5());if (!file.exists()) { // 没有文件创建文件file.mkdir();}File chunkFile = new File(fileStorePath + "/" + param.getMd5() + "/" + chunkIndex);try {FileUtils.copyInputStreamToFile(param.getFile().getInputStream(), chunkFile); // 流文件操作} catch (Exception e) {jr.setResultCode(-1);e.printStackTrace();}}logger.info("文件-:{}的小标-:{},上传成功", fileName, chunkIndex);File dir = new File(fileStorePath + "/" + param.getMd5());File[] childs = dir.listFiles();if(childs!=null){jr.setResultData(childs.length); // 返回上传了几个,即为上传进度}return jr;}java 复制代码 /** * 上传文件 * @param param * @param request * @return * @throws Exception */ @RequestMapping(value = "/upload", method = RequestMethod.POST) @ResponseBody public JsonResult filewebUpload(MultipartFileParam param, HttpServletRequest request) { JsonResult jr = new JsonResult(); boolean isMultipart = ServletFileUpload.isMultipartContent(request); // 文件名 String fileName = param.getName(); // 文件每次分片的下标 int chunkIndex = param.getChunk(); if (isMultipart) { File file = new File(fileStorePath + "/" + param.getMd5()); if (!file.exists()) { // 没有文件创建文件 file.mkdir(); } File chunkFile = new File( fileStorePath + "/" + param.getMd5() + "/" + chunkIndex); try { FileUtils.copyInputStreamToFile(param.getFile().getInputStream(), chunkFile); // 流文件操作 } catch (Exception e) { jr.setResultCode(-1); e.printStackTrace(); } } logger.info("文件-:{}的小标-:{},上传成功", fileName, chunkIndex); File dir = new File(fileStorePath + "/" + param.getMd5()); File[] childs = dir.listFiles(); if(childs!=null){ jr.setResultData(childs.length); // 返回上传了几个,即为上传进度 } return jr; }java 复制代码 /** * 上传文件 * @param param * @param request * @return * @throws Exception */ @RequestMapping(value = "/upload", method = RequestMethod.POST) @ResponseBody public JsonResult filewebUpload(MultipartFileParam param, HttpServletRequest request) { JsonResult jr = new JsonResult(); boolean isMultipart = ServletFileUpload.isMultipartContent(request); // 文件名 String fileName = param.getName(); // 文件每次分片的下标 int chunkIndex = param.getChunk(); if (isMultipart) { File file = new File(fileStorePath + "/" + param.getMd5()); if (!file.exists()) { // 没有文件创建文件 file.mkdir(); } File chunkFile = new File( fileStorePath + "/" + param.getMd5() + "/" + chunkIndex); try { FileUtils.copyInputStreamToFile(param.getFile().getInputStream(), chunkFile); // 流文件操作 } catch (Exception e) { jr.setResultCode(-1); e.printStackTrace(); } } logger.info("文件-:{}的小标-:{},上传成功", fileName, chunkIndex); File dir = new File(fileStorePath + "/" + param.getMd5()); File[] childs = dir.listFiles(); if(childs!=null){ jr.setResultData(childs.length); // 返回上传了几个,即为上传进度 } return jr; }
最后合并这些文件的分片
添一个上传文件的效果图
- 由上述动态图,我们可以看到把文件切割成了12份,所以发送了12个上传分片请求
- 当然,上传完成以后,最后,再发一个请求,告知后端去合并这些一片片文件即可
- 即merge请求,当然也要带上此大文件的hash值
- 告知后端具体合并哪一个文件,这样才不会出错
前端代码
js复制代码// 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求Promise.allSettled(requestListFn).then(async (many) => {// 都上传完毕了,文件上传进度条就为100%了fileProgress.value = 100;// 最后再告知后端合并一下已经上传的文件碎片了即可const loading = ElLoading.service({lock: true,text: "文件合并中,请稍后???...",background: "rgba(0, 0, 0, 0.7)",});const res = await tellBackendMergeFn(fileName, fileHash.value);if (res.data.resultCode === 0) {console.log("文件并合成功,大文件上传任务完成");loading.close();} else {console.log("文件并合失败,大文件上传任务未完成");loading.close();}});js 复制代码 // 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求 Promise.allSettled(requestListFn).then(async (many) => { // 都上传完毕了,文件上传进度条就为100%了 fileProgress.value = 100; // 最后再告知后端合并一下已经上传的文件碎片了即可 const loading = ElLoading.service({ lock: true, text: "文件合并中,请稍后???...", background: "rgba(0, 0, 0, 0.7)", }); const res = await tellBackendMergeFn(fileName, fileHash.value); if (res.data.resultCode === 0) { console.log("文件并合成功,大文件上传任务完成"); loading.close(); } else { console.log("文件并合失败,大文件上传任务未完成"); loading.close(); } });js 复制代码 // 使用allSettled发请求好一些,挂了的就挂了,不影响后续不挂的请求 Promise.allSettled(requestListFn).then(async (many) => { // 都上传完毕了,文件上传进度条就为100%了 fileProgress.value = 100; // 最后再告知后端合并一下已经上传的文件碎片了即可 const loading = ElLoading.service({ lock: true, text: "文件合并中,请稍后???...", background: "rgba(0, 0, 0, 0.7)", }); const res = await tellBackendMergeFn(fileName, fileHash.value); if (res.data.resultCode === 0) { console.log("文件并合成功,大文件上传任务完成"); loading.close(); } else { console.log("文件并合失败,大文件上传任务未完成"); loading.close(); } });
后端代码
java复制代码/*** 分片上传成功之后,合并文件* @param request* @return*/@RequestMapping(value = "/merge", method = RequestMethod.POST)@ResponseBodypublic JsonResult filewebMerge(HttpServletRequest request) {FileChannel outChannel = null;JsonResult jr = new JsonResult();int code =0;try {String fileName = request.getParameter("fileName");String fileMd5 = request.getParameter("fileMd5");// 读取目录里的所有文件File dir = new File(fileStorePath + "/" + fileMd5);File[] childs = dir.listFiles();if (Objects.isNull(childs) || childs.length == 0) {jr.setResultCode(-1);return jr;}// 转成集合,便于排序List<File> fileList = new ArrayList<File>(Arrays.asList(childs));Collections.sort(fileList, new Comparator<File>() {@Overridepublic int compare(File o1, File o2) {if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {return -1;}return 1;}});// 合并后的文件File outputFile = new File(fileStorePath + "/" + "merge" + "/" + fileMd5 + "/" + fileName);// 创建文件if (!outputFile.exists()) {File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5);if (!mergeMd5Dir.exists()) {mergeMd5Dir.mkdirs();}logger.info("创建文件");outputFile.createNewFile();}outChannel = new FileOutputStream(outputFile).getChannel();FileChannel inChannel = null;try {for (File file : fileList) {inChannel = new FileInputStream(file).getChannel();inChannel.transferTo(0, inChannel.size(), outChannel);inChannel.close();// 删除分片file.delete();}} catch (Exception e) {code =-1;e.printStackTrace();//发生异常,文件合并失败 ,删除创建的文件outputFile.delete();dir.delete();//删除文件夹} finally {if (inChannel != null) {inChannel.close();}}dir.delete(); //删除分片所在的文件夹} catch (IOException e) {code =-1;e.printStackTrace();} finally {try {if (outChannel != null) {outChannel.close();}} catch (IOException e) {e.printStackTrace();}}jr.setResultCode(code);return jr;}}java 复制代码 /** * 分片上传成功之后,合并文件 * @param request * @return */ @RequestMapping(value = "/merge", method = RequestMethod.POST) @ResponseBody public JsonResult filewebMerge(HttpServletRequest request) { FileChannel outChannel = null; JsonResult jr = new JsonResult(); int code =0; try { String fileName = request.getParameter("fileName"); String fileMd5 = request.getParameter("fileMd5"); // 读取目录里的所有文件 File dir = new File(fileStorePath + "/" + fileMd5); File[] childs = dir.listFiles(); if (Objects.isNull(childs) || childs.length == 0) { jr.setResultCode(-1); return jr; } // 转成集合,便于排序 List<File> fileList = new ArrayList<File>(Arrays.asList(childs)); Collections.sort(fileList, new Comparator<File>() { @Override public int compare(File o1, File o2) { if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) { return -1; } return 1; } }); // 合并后的文件 File outputFile = new File(fileStorePath + "/" + "merge" + "/" + fileMd5 + "/" + fileName); // 创建文件 if (!outputFile.exists()) { File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5); if (!mergeMd5Dir.exists()) { mergeMd5Dir.mkdirs(); } logger.info("创建文件"); outputFile.createNewFile(); } outChannel = new FileOutputStream(outputFile).getChannel(); FileChannel inChannel = null; try { for (File file : fileList) { inChannel = new FileInputStream(file).getChannel(); inChannel.transferTo(0, inChannel.size(), outChannel); inChannel.close(); // 删除分片 file.delete(); } } catch (Exception e) { code =-1; e.printStackTrace(); //发生异常,文件合并失败 ,删除创建的文件 outputFile.delete(); dir.delete();//删除文件夹 } finally { if (inChannel != null) { inChannel.close(); } } dir.delete(); //删除分片所在的文件夹 } catch (IOException e) { code =-1; e.printStackTrace(); } finally { try { if (outChannel != null) { outChannel.close(); } } catch (IOException e) { e.printStackTrace(); } } jr.setResultCode(code); return jr; } }java 复制代码 /** * 分片上传成功之后,合并文件 * @param request * @return */ @RequestMapping(value = "/merge", method = RequestMethod.POST) @ResponseBody public JsonResult filewebMerge(HttpServletRequest request) { FileChannel outChannel = null; JsonResult jr = new JsonResult(); int code =0; try { String fileName = request.getParameter("fileName"); String fileMd5 = request.getParameter("fileMd5"); // 读取目录里的所有文件 File dir = new File(fileStorePath + "/" + fileMd5); File[] childs = dir.listFiles(); if (Objects.isNull(childs) || childs.length == 0) { jr.setResultCode(-1); return jr; } // 转成集合,便于排序 List<File> fileList = new ArrayList<File>(Arrays.asList(childs)); Collections.sort(fileList, new Comparator<File>() { @Override public int compare(File o1, File o2) { if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) { return -1; } return 1; } }); // 合并后的文件 File outputFile = new File(fileStorePath + "/" + "merge" + "/" + fileMd5 + "/" + fileName); // 创建文件 if (!outputFile.exists()) { File mergeMd5Dir = new File(fileStorePath + "/" + "merge" + "/" + fileMd5); if (!mergeMd5Dir.exists()) { mergeMd5Dir.mkdirs(); } logger.info("创建文件"); outputFile.createNewFile(); } outChannel = new FileOutputStream(outputFile).getChannel(); FileChannel inChannel = null; try { for (File file : fileList) { inChannel = new FileInputStream(file).getChannel(); inChannel.transferTo(0, inChannel.size(), outChannel); inChannel.close(); // 删除分片 file.delete(); } } catch (Exception e) { code =-1; e.printStackTrace(); //发生异常,文件合并失败 ,删除创建的文件 outputFile.delete(); dir.delete();//删除文件夹 } finally { if (inChannel != null) { inChannel.close(); } } dir.delete(); //删除分片所在的文件夹 } catch (IOException e) { code =-1; e.printStackTrace(); } finally { try { if (outChannel != null) { outChannel.close(); } } catch (IOException e) { e.printStackTrace(); } } jr.setResultCode(code); return jr; } }
至此,大文件上传的三步都完成了
大文件上传解决方案:
- 第一步,大文件拆分成一片又一片(分片操作)✔️
- 第二步,每一次请求给后端带一片文件(分片上传)✔️
- 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可✔️
- 笔者用本机测试了一下,两三个G的文件都是没有问题的
- 实际项目上线,大文件上传功能,会受到网络带宽、设备性能等各种因素影响
- 一定要注意文件分片时切分的大小,例:
CHUNK_SIZE = 5 * 1024 * 1024;
- 这个文件的大小决定了切割多少片,决定了并发多少请求(不可过大,也不可能非常小)
- 太大单个请求就太慢了,太小浏览器一次发几千上万个请求,也扛不住
辅助线程去优化
- 开启辅助线程计算大文件的hash值
至此,大文件上传的三步都完成了
大文件上传解决方案:
- 第一步,大文件拆分成一片又一片(分片操作)✔️
- 第二步,每一次请求给后端带一片文件(分片上传)✔️
- 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可✔️
- 笔者用本机测试了一下,两三个G的文件都是没有问题的
- 实际项目上线,大文件上传功能,会受到网络带宽、设备性能等各种因素影响
- 一定要注意文件分片时切分的大小,例:
CHUNK_SIZE = 5 * 1024 * 1024;
- 这个文件的大小决定了切割多少片,决定了并发多少请求(不可过大,也不可能非常小)
- 太大单个请求就太慢了,太小浏览器一次发几千上万个请求,也扛不住
辅助线程去优化
开启辅助线程计算大文件的hash值
首先,定义函数异步,开启辅助线程,计算
js复制代码const calFileMd5ByThreadFn = (chunks) => {return new Promise((resolve) => {worker = new Worker("./hash.js"); // 实例化一个webworker线程worker.postMessage({ chunks }); // 主线程向辅助线程传递数据,发分片数组用于计算worker.onmessage = (e) => {const { hash } = e.data; // 辅助线程将相关计算数据发给主线程hashProgress.value = e.data.hashProgress; // 更改进度条if (hash) {// 当hash值被算出来时,就可以关闭主线程了worker.terminate();resolve(hash); // 将结果带出去}};});};js 复制代码 const calFileMd5ByThreadFn = (chunks) => { return new Promise((resolve) => { worker = new Worker("./hash.js"); // 实例化一个webworker线程 worker.postMessage({ chunks }); // 主线程向辅助线程传递数据,发分片数组用于计算 worker.onmessage = (e) => { const { hash } = e.data; // 辅助线程将相关计算数据发给主线程 hashProgress.value = e.data.hashProgress; // 更改进度条 if (hash) { // 当hash值被算出来时,就可以关闭主线程了 worker.terminate(); resolve(hash); // 将结果带出去 } }; }); };js 复制代码 const calFileMd5ByThreadFn = (chunks) => { return new Promise((resolve) => { worker = new Worker("./hash.js"); // 实例化一个webworker线程 worker.postMessage({ chunks }); // 主线程向辅助线程传递数据,发分片数组用于计算 worker.onmessage = (e) => { const { hash } = e.data; // 辅助线程将相关计算数据发给主线程 hashProgress.value = e.data.hashProgress; // 更改进度条 if (hash) { // 当hash值被算出来时,就可以关闭主线程了 worker.terminate(); resolve(hash); // 将结果带出去 } }; }); };
然后,在public目录下新建hash.js去撰写辅助线程代码
js复制代码// 使用importScripts引入cdn使用self.importScripts('https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js')self.onmessage = e => {const { chunks } = e.data // 获取到分片数组const spark = new self.SparkMD5.ArrayBuffer() // 实例化spark对象用于计算文件hashlet currentChunk = 0let fileReader = new FileReader()fileReader.onload = (e) => {spark.append(e.target.result)currentChunk = currentChunk + 1if (currentChunk < chunks.length) {fileReader.readAsArrayBuffer(chunks[currentChunk])// 未曾计算完只告知主线程计算进度self.postMessage({hashProgress: Math.ceil(currentChunk / chunks.length * 100)})} else {// 计算完了进度和hash结果就都可以告知了self.postMessage({hash: spark.end(),hashProgress: 100})self.close();}}fileReader.readAsArrayBuffer(chunks[currentChunk])}js 复制代码 // 使用importScripts引入cdn使用 self.importScripts('https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js') self.onmessage = e => { const { chunks } = e.data // 获取到分片数组 const spark = new self.SparkMD5.ArrayBuffer() // 实例化spark对象用于计算文件hash let currentChunk = 0 let fileReader = new FileReader() fileReader.onload = (e) => { spark.append(e.target.result) currentChunk = currentChunk + 1 if (currentChunk < chunks.length) { fileReader.readAsArrayBuffer(chunks[currentChunk]) // 未曾计算完只告知主线程计算进度 self.postMessage({ hashProgress: Math.ceil(currentChunk / chunks.length * 100) }) } else { // 计算完了进度和hash结果就都可以告知了 self.postMessage({ hash: spark.end(), hashProgress: 100 }) self.close(); } } fileReader.readAsArrayBuffer(chunks[currentChunk]) }js 复制代码 // 使用importScripts引入cdn使用 self.importScripts('https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js') self.onmessage = e => { const { chunks } = e.data // 获取到分片数组 const spark = new self.SparkMD5.ArrayBuffer() // 实例化spark对象用于计算文件hash let currentChunk = 0 let fileReader = new FileReader() fileReader.onload = (e) => { spark.append(e.target.result) currentChunk = currentChunk + 1 if (currentChunk < chunks.length) { fileReader.readAsArrayBuffer(chunks[currentChunk]) // 未曾计算完只告知主线程计算进度 self.postMessage({ hashProgress: Math.ceil(currentChunk / chunks.length * 100) }) } else { // 计算完了进度和hash结果就都可以告知了 self.postMessage({ hash: spark.end(), hashProgress: 100 }) self.close(); } } fileReader.readAsArrayBuffer(chunks[currentChunk]) }
使用的话,直接传递分好片文件数组参数即可
js复制代码const fileMd5 = await calFileMd5ByThreadFn(chunks); // 根据分片计算console.log('hash',fileMd5) // 得出此大文件的hash值了js 复制代码 const fileMd5 = await calFileMd5ByThreadFn(chunks); // 根据分片计算 console.log('hash',fileMd5) // 得出此大文件的hash值了js 复制代码 const fileMd5 = await calFileMd5ByThreadFn(chunks); // 根据分片计算 console.log('hash',fileMd5) // 得出此大文件的hash值了
单纯计算加减乘除啥的倒是可以使用vue-worker这个插件,参见笔者之前的文章:juejin.cn/post/719847…
这样的话,速度就会快一些了…
附录
- 大文件上传流程图
- 当我们把上述文章读完以后,一个大文件上传的流程图就清晰的浮现在我们的脑海中了
- 笔者用processOn画了一个流程图,如下:
- 代码仓库
- 参考资料
- webuploader(百度团队开源项目):fex.baidu.com/webuploader…
- 大文件上传:juejin.cn/post/717704…
- Buzut:github.com/Buzut/huge-…