大文件上传也是面试中老生常谈的面试题了,在工作中也是十分常见,比如视频网站上传视频等。也许大部分人是通过现成的框架去实现大文件的分片上传、断点续传等,那它具体实现的原理是什么,可能很多人就说不出一二三了。
今天分享一篇比较优秀的文章,来详细的讲解一下如何去做大文件上传。学会了,在你的简历上就可以当成项目亮点来体现了😁😁
❝原文地址:https://juejin.cn/post/7218113760857980985
原文作者:水冗水孚
❞
以下是正文:
笔者的一个好友上个月被裁,最近在面试求职,在面试时,最后一个问题是问他有没有做过大文件上传功能,我朋友说没做过...
❝现在的就业环境不太好,要求都比之前高一些,当然也有可能面试官刷面试KPI的,或者这个岗位不急着找人,慢慢面试呗。毕竟也算是自己的工作量,能写进周报里面...
❞
❝对于我们每个人而言:【「生于黑暗,追逐黎明」————《异兽迷城》】
❞
既然面试官会问,那咱们就一起来看看,大文件上传功能如何实现吧...
在我们工作中,上传功能最常见的就是excel的上传功能,一般来说,一个excel的大小在10MB以内吧,如果有好几十MB的excel,就勉强算是中等文件吧,此时,我们需要设置nginx的client_max_body_size
值,将其放开,只不过一次上传一个几十MB的文件,接口会慢一些,不过也勉强能够接受。
❝前端手握狼牙棒,后端手持流星锤,对产品朗声笑道:
❞要是不能接受,就请忍受🙂🙂🙂
但是,如果一个文件有几百兆,或者好几个G呢?上述方式就不合适了。
既然「一次性上传不行」,那么咱们就把「大文件拆分」开来,进行分批、分堆、「分片」、一点点上传的操作,等上传完了,再将「一片片文件合并」一起,再「恢复成原来的样子」即可
❝最常见的这个需求,就是视频的上传,比如:腾讯视频创作平台、哔哩哔哩后台等...
❞
一共三步即可:
❝文件分片操作大致可分为上述三步骤,但在这三步骤中,还有一些细节需要我们注意,这个后文中会一一说到,我们继续往下阅读
❞
为便于更好理解,我们看一下已经做好的效果图:
由上述效果图,我们可以看到,一个58MB的大文件,被分成了12片上传,**很快啊
**!上传完成。
「思考两个问题:」
「解决方案就是:」
那新的问题又来了:
「前端如何才能确定文件的id,如何才能得到文件的唯一标识?」
树上没有两片相同的叶子,天上没有两朵相同的云彩,文件是独一无二的(前提是内容不同,复制一份的不算)
who know?
spark-md5怪笑一声: 寡人知晓!
spark-md5是基于md5的一种优秀的算法,用处很多,其中就可以去计算文件的唯一身份证标识-hash值
❝当然还有别的工具库,如CryptoJS也可以计算文件的hash值,不过spark-md5更主流、更优秀
❞
直接计算一整个文件的hash值:
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></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问题也就迎刃而解了
❞
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兆多的文件去分片):
有了上述分好片的chunks数组(数组中存放一片又一片小文件),再结合spark-md5,使用递归的写法,一片一片的再去读取计算,最终算出结果
/**
* 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])
})
}
使用:
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位的字符串
}
至此,我们大文件分片上传操作,已经完成了三分之一了。我们已经完成了大文件的分片和计算大文件的hash值唯一身份证id(实际上,计算大文件的hash值,还是挺耗费时长的,优化方案就是开一个辅助线程进行异步计算操作,不过这个是优化的点,文末会提到)
「接下来,就到了第二步,发请求环节:将已经分好片的每一片和这个大文件的hash值作为参数传递给后端(当然还有别的参数,比如文件名、文件分了多少片,每次上传的是那一片【索引】等---看后端定义)」
❝「大文件上传解决方案:」
❞
第一步,大文件拆分成一片又一片(分片操作)✔️ 第二步,每一次请求给后端带一片文件(分片上传) 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可
诗曰:
分片上传发请求,一片就是一请求。
请求之前带校验,这样操作才规范。
「校验逻辑思路如下:」
「比如,如下状态码:」
「对应前端代码:」
以下代码举例是vue3的语法举例,大家知道每一步做什么即可,文章看完,建议大家去笔者的github仓库把前后端代码,都拉下来跑起来,结合代码中的注释,才能够更好的理解
html结构
<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
「对应后端代码:」
笔者后端代码是springboot
private String fileStorePath = "F:kkk";
// 大文件上传操作在F盘下的kkk文件夹中操作
/**
* @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;
}
前端根据接口的状态码,作相应控制,没上传过正常操作,曾经上传过了,就做个提示文件已上传。这里需要特别注意一下,曾经上传中断的情况
我们来捋一下逻辑就明晰了:
{resultCode:2 , resultData:[0,8,9]}
// 等于2表示曾经上传过一部分,现在要继续上传
if (res.data.resultCode == 2) {
// 若是文件曾上传过一部分,后端会返回上传过得部分的文件索引,前端通过索引可以知道哪些
// 上传过,做一个过滤,已上传的文件就不用继续上传了,上传未上传过的文件片
doneFileList = res.data.resultData.map((item) => {
return item * 1; // 后端给到的是字符串索引,这里转成数字索引
});
}
doneFileList数组存储的就是后端返回的,曾经上传过一部分的数组分片文件索引
比如下面这两张图,就是文件曾经上传中断以后的,再次上传的检查接口返回的数据
示例图一:
返回的是分片文件的名,也就是分片的索引,如下图:
前端根据doneFileList判断,去准备参数
// 说明没有上传过,组装一下,直接使用
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);
上述代码实现了,正常上传以及「曾经中断过的文件继续上传」,这就是「断点续传」
上述代码实现了,正常上传以及「曾经中断过的文件继续上传」,这就是「断点续传」
上述代码实现了,正常上传以及「曾经中断过的文件继续上传」,这就是「断点续传」
前端代码
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%了
});
};
后端代码
/**
* 上传文件
* @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;
}
添一个上传文件的效果图
前端代码
// 使用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();
}
});
后端代码
/**
* 分片上传成功之后,合并文件
* @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;
}
}
至此,大文件上传的三步都完成了
❝「大文件上传解决方案:」
❞
第一步,大文件拆分成一片又一片(分片操作)✔️ 第二步,每一次请求给后端带一片文件(分片上传)✔️ 第三步,当每一片文件都上传完,再发请求告知后端将分片的文件合并即可✔️
CHUNK_SIZE = 5 * 1024 * 1024;
首先,定义函数异步,开启辅助线程,计算
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去撰写辅助线程代码
// 使用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])
}
使用的话,直接传递分好片文件数组参数即可
const fileMd5 = await calFileMd5ByThreadFn(chunks); // 根据分片计算
console.log('hash',fileMd5) // 得出此大文件的hash值了
❝单纯计算加减乘除啥的倒是可以使用vue-worker这个插件,参见笔者之前的文章:https://juejin.cn/post/7198476152624595005
❞
这样的话,速度就会快一些了...
觉得本文有用的小伙伴,可以帮忙点个“在看”,让更多的朋友看到咱们的文章。
最后,再给“前端面试题宝典”的辅导服务打下广告,目前有面试「全流程辅导、简历指导、模拟面试、零基础辅导和付费咨询的增值服务」,如果有感兴趣的伙伴,可以联系小助手(微信号:interview-fe)了解详情哦~