文件上传功能可以说对于后端服务是必须的,不同场景对文件上传的要求也各不相同,有的追求速度,有的注重稳定性,还有的需要考虑文件大小和安全性。所以便有了秒传、断点续传和分片上传等解决方案。
1、总述
秒传
秒传,顾名思义,就是几乎瞬间完成文件上传的过程。其实现原理是通过计算文件的哈希值(如 MD5 或 SHA-1),然后将这个唯一的标识符发送给服务器。如果服务器上已经存在相同的文件,则直接返回成功信息,避免了重复上传。这种方式不仅节省了带宽,也大大提高了用户体验。
断点续传
断点续传是指在网络不稳定或者用户主动中断上传后,能够从上次中断的地方继续上传,而不需要重新开始整个过程。这对于大文件上传尤为重要,因为它可以有效防止因网络问题导致的上传失败,同时也能节约用户的流量和时间。
分片上传
分片上传则是将一个大文件分割成多个小块分别上传,最后再由服务器合并成完整的文件。这种做法的好处是可以并行处理多个小文件,提高上传效率;同时,如果某一部分上传失败,只需要重传这一部分,不影响其他部分。
2、秒传
后端
在 SpringBoot 项目中,我们可以使用 MessageDigest
类来计算文件的 MD5 值,然后检查数据库中是否存在该文件。
@RestController
@RequestMapping("/file")
public class FileController {@AutowiredFileService fileService;@PostMapping("/upload1")public ResponseEntity<String> secondUpload(@RequestParam(value = "file",required = false) MultipartFile file,@RequestParam(required = false,value = "md5") String md5) {try {// 检查数据库中是否已存在该文件if (fileService.existsByMd5(md5)) {return ResponseEntity.ok("文件已存在");}// 保存文件到服务器file.transferTo(new File("/path/to/save/" + file.getOriginalFilename()));// 保存文件信息到数据库fileService.save(new FileInfo(file.getOriginalFilename(), DigestUtils.md5DigestAsHex(file.getInputStream())));return ResponseEntity.ok("上传成功");} catch (Exception e) {return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("上传失败");}}
}
前端
前端可以通过 JavaScript 的 FileReader API 读取文件内容,通过 spark-md5 计算 MD5 值,然后发送给后端进行校验。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>秒传</title><script src="spark-md5.js"></script>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<hr>
<script>async function startUpload() {const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("请选择文件");return;}const md5 = await calculateMd5(file);const formData = new FormData();formData.append('md5', md5);const response = await fetch('/file/upload1', {method: 'POST',body: formData});const result = await response.text();if (response.ok) {if (result != "文件已存在") {// 开始上传文件}} else {console.error("上传失败: " + result);}}function calculateMd5(file) {return new Promise((resolve, reject) => {const reader = new FileReader();reader.onloadend = () => {const spark = new SparkMD5.ArrayBuffer();spark.append(reader.result);resolve(spark.end());};reader.onerror = () => reject(reader.error);reader.readAsArrayBuffer(file);});}
</script>
</body>
</html>
前端分为两个步骤:
- 计算文件的 MD5 值,计算之后发送给服务端确定文件是否存在。
- 如果文件已经存在,则不需要继续上传文件;如果文件不存在,则开始上传文件,上传文件和 MD5 校验请求类似,上面的案例代码中我就没有重复演示了,松哥在书里和之前的课程里都多次讲过文件上传,这里不再啰嗦。
3、分片上传
分片上传关键是在前端对文件切片,比如一个 100MB 的文件切为 50 份,每份 2MB。每次上传的时候,需要多一个参数记录当前上传的文件切片的起始位置。
比如:一个 10MB 的文件,切为 10 份,每份 1MB,那么:
- 第 0 片,从 0 开始,一共是
1024*1024
个字节。 - 第 1 片,从
1024*1024
开始,一共是1024*1024
个字节。 - 第 2 片…
把这个搞懂,后面的代码就好理解了。
后端
private static final String UPLOAD_DIR = System.getProperty("user.home") + "/uploads/";
/*** 上传文件到指定位置** @param file 上传的文件* @param start 文件开始上传的位置* @return ResponseEntity<String> 上传结果*/
@PostMapping("/upload2")
public ResponseEntity<String> resumeUpload(@RequestParam("file") MultipartFile file, @RequestParam("start") long start,@RequestParam("fileName") String fileName) {try {File directory = new File(UPLOAD_DIR);if (!directory.exists()) {directory.mkdirs();}File targetFile = new File(UPLOAD_DIR + fileName);RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "rw");FileChannel channel = randomAccessFile.getChannel();channel.position(start);channel.transferFrom(file.getResource().readableChannel(), start, file.getSize());channel.close();randomAccessFile.close();return ResponseEntity.ok("上传成功");} catch (Exception e) {System.out.println("上传失败: "+e.getMessage());return ResponseEntity.status(500).body("上传失败");}
}
后端每次处理的时候,需要先设置文件的起始位置。
前端
前端需要将文件切分成多个小块,然后依次上传。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>分片示例</title>
</head>
<body><input type="file" id="fileInput" /><button onclick="startUpload()">开始上传</button><script>async function startUpload() {const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("请选择文件");return;}const filename = file.name;let start = 0;uploadFile(file, start);}async function uploadFile(file, start) {const chunkSize = 1024 * 1024; // 每个分片1MBconst total = Math.ceil(file.size / chunkSize);for (let i = 0; i < total; i++) {const chunkStart = start + i * chunkSize;const chunkEnd = Math.min(chunkStart + chunkSize, file.size);const chunk = file.slice(chunkStart, chunkEnd);const formData = new FormData();formData.append('file', chunk);formData.append('start', chunkStart);formData.append('fileName', file.name);const response = await fetch('/file/upload2', {method: 'POST',body: formData});const result = await response.text();if (response.ok) {console.log(`分片 ${i + 1}/${total} 上传成功`);} else {console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);break;}}}</script>
</body>
</html>
4、断点续传
断点续传的技术原理类似于 分片上传。
当文件已经上传了一部分之后,断了需要重新开始上传。
大概思路是这样的:
- 前端先发送一个请求,检查要上传的文件在服务端是否已经存在,如果存在,目前大小是多少。
- 前端根据已经存在的大小,继续上传文件即可。
后端
后端检查的接口,如下:
@GetMapping("/check")
public ResponseEntity<Long> checkFile(@RequestParam("filename") String filename) {File file = new File(UPLOAD_DIR + filename);if (file.exists()) {return ResponseEntity.ok(file.length());} else {return ResponseEntity.ok(0L);}
}
如果文件存在,则返回已经存在的文件大小。
如果文件不存在,则返回 0,表示前端从头开始上传该文件。
前端
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>断点续传示例</title>
</head>
<body>
<input type="file" id="fileInput"/>
<button onclick="startUpload()">开始上传</button><script>async function startUpload() {const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("请选择文件");return;}const filename = file.name;let start = await checkFile(filename);uploadFile(file, start);}async function checkFile(filename) {const response = await fetch(`/file/check?filename=${filename}`);const start = await response.json();return start;}async function uploadFile(file, start) {const chunkSize = 1024 * 1024; // 每个分片1MBconst total = Math.ceil((file.size - start) / chunkSize);for (let i = 0; i < total; i++) {const chunkStart = start + i * chunkSize;const chunkEnd = Math.min(chunkStart + chunkSize, file.size);const chunk = file.slice(chunkStart, chunkEnd);const formData = new FormData();formData.append('file', chunk);formData.append('start', chunkStart);formData.append('fileName', file.name);const response = await fetch('/file/upload2', {method: 'POST',body: formData});const result = await response.text();if (response.ok) {console.log(`分片 ${i + 1}/${total} 上传成功`);} else {console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);break;}}}
</script>
</body>
</html>