目录
一、为什么选择用切片
对于文件通常前端向后端发送post或表单请求,基本是没什么问题的,但是大文件的上传是一个特殊的情况,不管怎样简单的需求,在量级达到一定层次时,都会变得异常复杂; 上传大文件时,以下几个变量会影响我们的用户体验,服务器处理数据的能力,请求超时,网络波动,失败后又需要重新上传等等。
其实分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块 (Part) 来进行分片上传,从而减少请求时间,如何有某个分片请求失败,只需要重新发送即可,就不需要重新上传!
二、切片原理解析
在 JavaScript
中,文件 File
对象是 Blob
对象的子类,Blob
对象包含一个重要的方法 slice
,通过这个方法,我们就可以对二进制文件进行拆分。将文件拆分成 size
大小(可以是100k、500k、1M…)的分块,得到一个 file
的数组 fileChunkList
,然后每次请求只需要上传这一个部分的分块即可。
// 生成文件切片
function createFileChunk(file, size = SIZE) {
const fileChunkList = []
let cur = 0
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + size),
})
cur += size
}
return fileChunkList
}
拿到原文件的 hash
值是关键的一步,同一个文件就算改文件名,hash
值也不会变,就可以避免文件改名后重复上传的问题。这里,我们使用 spark-md5.min.js 来根据文件的二进制内容计算文件的 hash
。我们传入切片后的 fileChunkList
,利用 FileReader
读取每个切片的 ArrayBuffer
并不断传入 spark-md5
中,每计算完一个切片通过 postMessage
向主线程发送一个进度事件,全部完成后将最终的 hash
发送给主线程。
// 计算 hash 代码
// public/hash.js
self.onmessage = e => {
const { fileChunkList } = e.data
const spark = new self.SparkMD5.ArrayBuffer()
let percentage = 0
let count = 0
const loadNext = index => {
const reader = new FileReader()
reader.readAsArrayBuffer(fileChunkList[index].file)
reader.onload = e => {
count++
spark.append(e.target.result)
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
})
self.close()
} else {
percentage += 100 / fileChunkList.length
self.postMessage({
percentage
})
loadNext(count)
}
}
}
loadNext(count)
}
注意:考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash。
三、 代码展示
前端:
验证文件是否已经在服务端存在,如果 shouldUpload
为 false
,则表明这个文件不需要上传,相当于秒传成功。
/**
* 返回值说明
* shouldUpload:标识这个文件是否还需要上传
* uploadedList: 服务端存在该文件的切片List
*/
const { shouldUpload, uploadedList } = await verifyUpload(
container.file.name,
container.hash
)
在前端,需要使用 JavaScript 将文件分割成多个部分,并向后端发送分块数据。以下是一个示例代码:
javascript复制代码
function uploadFile(file) {
const chunkSize = 1024 * 1024; // 每个部分的大小(1MB)
const totalChunks = Math.ceil(file.size / chunkSize); // 总部分数
let currentChunk = 1;
let startByte = 0;
while (startByte < file.size) { // 分割文件为多个部分,并上传每个部分
const endByte = Math.min(startByte + chunkSize, file.size);
const chunk = file.slice(startByte, endByte);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileName', file.name);
formData.append('chunkNumber', currentChunk);
formData.append('totalChunks', totalChunks);
axios.post('/upload', formData);
startByte += chunkSize;
currentChunk++;
}
}
后端:
需要在项目中添加以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
存储分片和合并这里是写在一起了,可以拆开写,服务端根据文件标识,分片顺序进行合并,合并完以后删除分片文件。
@RestController
public class FileUploadController {
private static final String UPLOAD_DIRECTORY = "/tmp/uploads";
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file,
@RequestParam("fileName") String fileName,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks) throws IOException {
File uploadDirectory = new File(UPLOAD_DIRECTORY);
if (!uploadDirectory.exists()) {
uploadDirectory.mkdirs();
}
File destFile = new File(UPLOAD_DIRECTORY + File.separator + fileName + ".part" + chunkNumber);
FileUtils.copyInputStreamToFile(file.getInputStream(), destFile);
if (chunkNumber == totalChunks) { // 如果所有部分都已上传,则将它们组合成一个完整的文件
String targetFilePath = UPLOAD_DIRECTORY + File.separator + fileName;
for (int i = 1; i <= totalChunks; i++) {
File partFile = new File(UPLOAD_DIRECTORY + File.separator + fileName + ".part" + i);
try (FileOutputStream fos = new FileOutputStream(targetFilePath, true)) {
FileUtils.copyFile(partFile, fos);
partFile.delete();
}
}
}
return ResponseEntity.ok("Upload successful");
}
}
四、技术问题总结
显示进度:
我们可以通过 onprogress
事件来实时显示进度,默认情况下这个事件每 50ms
触发一次。需要注意的是,上传过程和下载过程触发的是不同对象 onprogress
事件:上传触发的是xhr.upload
对象的onprogress
事件,下载触发的是 xhr
对象的 onprogress
事件。
xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
if (event.lengthComputable) {
var completedPercent = event.loaded / event.total;
}
}
PS 特别提醒:
xhr.upload.onprogress
要写在xhr.send
方法前面。
暂停上传:
一个请求能被取消的前提是,我们需要将未收到响应的请求保存在一个列表中,然后依次调用每个 xhr
对象的 abort
方法。调用这个方法后,xhr
对象会停止触发事件,将请求的 status
置为 0
,并且无法访问任何与响应有关的属性。
/**
* 暂停
*/
function handlePause() {
requestListArr.value.forEach((xhr) => xhr?.abort())
requestListArr.value = []
}
从后端的角度看,一个上传请求被取消,意味着当前浏览器不会再向后端传输数据流,后端此时会报错,如下,错误信息也很清楚,就是文件还没到末尾就被客户端中断。当前文件切片写入失败。
Hash的优化空间:
计算hash
耗时的问题,不仅可以通过 web-workder
,还可以参考 React
的 FFiber
架构,通过 requestIdleCallback
来利用浏览器的空闲时间计算,也不会卡死主线程;如果觉得文件全量 Hash 比较慢的话,还有一种方式就是计算抽样 Hash,减少计算的字节数可以大幅度减少耗时;在前文的代码中,我们是将大文件切片后,全量传入 spark-md5.min.js 中来根据文件的二进制内容计算文件的 hash
的。那么,举个例子,我们可以这样优化: 文件切片以后,取第一个和最后一个切片全部内容,其他切片的取 首中尾 三个地方各2各字节来计算 hash
。这样来计算文件 hash
会快很多。
限制请求个数:
解决了大文件计算 hash
的时间优化问题;下一个问题是:如果一个大文件切了成百上千来个切片,一次发几百个 http
请求,容易把浏览器搞崩溃。那么就需要控制并发,也就是限制请求个数。思路就是我们把异步请求放在一个队列里,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可。我们通过并发数 max
来管理并发数,发起一个请求 max--
,结束一个请求 max++
即可。