大文件上传断点续传的实现逻辑
大文件上传断点续传的核心在于将文件分成多个小片段(分片),逐个上传这些片段到服务器,并记录已成功上传的部分。如果上传中断,可以基于之前保存的状态重新恢复未完成部分的上传过程。
实现逻辑的关键点
-
前端处理
使用浏览器提供的
File API
和Blob.slice()
方法来切割文件成若干个小块。每一块可以通过 HTTP 请求发送至服务器。 -
唯一标识符
为了区分不同用户的上传请求以及同一文件的不同分片,通常会为每次上传生成唯一的标识符(UUID 或 MD5 值)。该标识符用于标记当前上传的任务状态。
-
进度跟踪与存储
客户端通过本地缓存机制(如 IndexedDB、LocalStorage)或 Cookies 来记录已完成的分片编号;而服务端则维护一份全局状态表,用来验证哪些分片已经收到并持久化下来。
-
错误重试机制
如果某个分片失败,则允许客户端自动尝试再次提交直到成功为止。这一步骤可通过设置定时器配合 Promise 链式调用来达成目标效果。
-
合并操作
当所有分片都到达服务器之后,由后台程序负责按照顺序拼接回原始数据流形式最后写入磁盘成为完整的文档副本。
Vue3 中的具体实现示例
以下是基于 Vue3 的简单版代码框架:
javascript
<template>
<div>
<input type="file" @change="handleFileChange"/>
<button @click="uploadChunks">开始上传</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const file = ref(null);
const chunkSize = 1 * 1024 * 1024; // 每次上传大小设为1M字节
function handleFileChange(event) {
file.value = event.target.files[0];
}
async function uploadChunks() {
const chunks = [];
let currentChunk = 0;
while (currentChunk * chunkSize < file.value.size) {
const start = currentChunk * chunkSize;
const end = Math.min(file.value.size, start + chunkSize);
chunks.push({
blob: file.value.slice(start, end),
index: currentChunk,
});
currentChunk++;
}
for (let i = 0; i < chunks.length; i++) {
try {
await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: chunks[i].blob,
});
console.log(`第${i}块上传完毕`);
} catch (error) {
console.error('上传失败:', error.message);
}
}
}
</script>
React 中的具体实现示例
下面是一个利用 Hooks 构建的大致流程展示:
jsx
import React, { useState } from 'react';
function App() {
const [selectedFile, setSelectedFile] = useState(null);
const handleChange = (event) => {
setSelectedFile(event.target.files[0]);
};
const handleSubmit = async () => {
if (!selectedFile) return alert('请选择要上传的文件');
const CHUNK_SIZE = 1 * 1024 * 1024; // 设置每个chunk为1MB
const totalChunks = Math.ceil(selectedFile.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; ++i) {
const start = i * CHUNK_SIZE;
const end = ((i + 1) * CHUNK_SIZE > selectedFile.size ? selectedFile.size : (i + 1) * CHUNK_SIZE);
const formData = new FormData();
formData.append('file', selectedFile.slice(start, end));
formData.append('index', i.toString());
try {
const response = await fetch("/upload", {
method: "POST",
body: formData,
});
if (!response.ok) throw Error(response.statusText);
} catch (err) {
console.warn(err);
}
}
};
return (
<>
<input type="file" onChange={handleChange}/>
<button onClick={handleSubmit}>Upload File</button>
</>
);
}
export default App;
Java Spring Boot 后端接收示例
Spring Boot 提供了强大的 RESTful 支持能力,在这里我们定义了一个简单的接口用以接受来自前端传递过来的数据包:
java
@RestController
@RequestMapping("/upload")
public class UploadController {
private static final String UPLOAD_DIR = "/tmp/uploads/";
@PostMapping(consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<String> handleFilePart(@RequestBody byte[] bytes,
@RequestParam Integer index,
HttpServletRequest request) throws IOException {
UUID taskId = extractTaskIdFromRequest(request);
Path targetLocation = Paths.get(UPLOAD_DIR).resolve(taskId.toString()).resolve(index + ".part");
Files.write(targetLocation, bytes);
return ResponseEntity.status(HttpStatus.CREATED).body("Uploaded part:" + index);
}
private UUID extractTaskIdFromRequest(HttpServletRequest req){
/* 解析header或者其他参数获取task id */
return null;
}
@GetMapping("/{taskId}")
public void mergeParts(@PathVariable String taskId)throws Exception{
List<Path> parts=Files.walk(Paths.get(UPLOAD_DIR))
.filter(p->p.toAbsolutePath().toString().contains(taskId+"\\"))
.sorted()
.collect(Collectors.toList());
RandomAccessFile raf=new RandomAccessFile(new File(UPLOAD_DIR+File.separator+taskId+".final"), "rw");
for(Path p:parts){
FileInputStream fis=new FileInputStream(p.toString());
byte [] buffer=new byte[(int)p.toFile().length()];
fis.read(buffer);
raf.write(buffer);
fis.close();
}
raf.close();
deleteTempFolderIfNecessary(parts.stream().map(Path::toFile).toArray(File[]::new));
}
private void deleteTempFolderIfNecessary(File...filesToDelete){/*清理临时目录*/}
}