SpringBoot 实战:文件上传之秒传、断点续传、分片上传

文件上传功能几乎是每个 Web 应用不可或缺的一部分。无论是个人博客中的图片上传,还是企业级应用中的文档管理,文件上传都扮演着至关重要的角色。今天,松哥和大家来聊聊文件上传中的几个高级玩法------秒传、断点续传和分片上传。

一 文件上传的常见场景

在日常开发中,文件上传的场景多种多样。比如,在线教育平台上的视频资源上传,社交平台上的图片分享,以及企业内部的知识文档管理等。这些场景对文件上传的要求也各不相同,有的追求速度,有的注重稳定性,还有的需要考虑文件大小和安全性。因此,针对不同需求,我们有了秒传、断点续传和分片上传等解决方案。

二 秒传、断点上传与分片上传

秒传

秒传,顾名思义,就是几乎瞬间完成文件上传的过程。其实现原理是通过计算文件的哈希值(如 MD5 或 SHA-1),然后将这个唯一的标识符发送给服务器。如果服务器上已经存在相同的文件,则直接返回成功信息,避免了重复上传。这种方式不仅节省了带宽,也大大提高了用户体验。

断点续传

断点续传是指在网络不稳定或者用户主动中断上传后,能够从上次中断的地方继续上传,而不需要重新开始整个过程。这对于大文件上传尤为重要,因为它可以有效防止因网络问题导致的上传失败,同时也能节约用户的流量和时间。

分片上传

分片上传则是将一个大文件分割成多个小块分别上传,最后再由服务器合并成完整的文件。这种做法的好处是可以并行处理多个小文件,提高上传效率;同时,如果某一部分上传失败,只需要重传这一部分,不影响其他部分。

三 秒传实战

后端实现

在 SpringBoot 项目中,我们可以使用 MessageDigest 类来计算文件的 MD5 值,然后检查数据库中是否存在该文件。

java 复制代码
@RestController
@RequestMapping("/file")
public class FileController {
    @Autowired
    FileService 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 值,然后发送给后端进行校验。

html 复制代码
<!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>

前端分为两个步骤:

  1. 计算文件的 MD5 值,计算之后发送给服务端确定文件是否存在。
  2. 如果文件已经存在,则不需要继续上传文件;如果文件不存在,则开始上传文件,上传文件和 MD5 校验请求类似,上面的案例代码中我就没有重复演示了,松哥在书里和之前的课程里都多次讲过文件上传,这里不再啰嗦。

四 分片上传实战

分片上传关键是在前端对文件切片,比如一个 10MB 的文件切为 10 份,每份 1MB。每次上传的时候,需要多一个参数记录当前上传的文件切片的起始位置。

比如一个 10MB 的文件,切为 10 份,每份 1MB,那么:

  • 第 0 片,从 0 开始,一共是 1024*1024 个字节。
  • 第 1 片,从 1024*1024 开始,一共是 1024*1024 个字节。
  • 第 2 片...

把这个搞懂,后面的代码就好理解了。

后端实现

java 复制代码
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("上传失败");
    }
}

后端每次处理的时候,需要先设置文件的起始位置。

前端调用

前端需要将文件切分成多个小块,然后依次上传。

html 复制代码
<!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; // 每个分片1MB
            const 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>

五 断点续传实战

断点续传的技术原理类似于分片上传。

当文件已经上传了一部分之后,断了需要重新开始上传。

那么我们的思路是这样的:

  1. 前端先发送一个请求,检查要上传的文件在服务端是否已经存在,如果存在,目前大小是多少。
  2. 前端根据已经存在的大小,继续上传文件即可。

后端案例

先来看后端检查的接口,如下:

java 复制代码
@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,表示前端从头开始上传该文件。

前端调用

html 复制代码
<!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; // 每个分片1MB
        const 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>

这个案例实际上是一个断点续传+分片上传的案例,相关知识点并不难,小伙伴们可以自行体会下。

六 总结

好了,以上就是关于文件上传中秒传、断点续传和分片上传的实战分享。通过这些技术的应用,我们可以极大地提升文件上传的效率和稳定性,改善用户体验。希望各位小伙伴在自己的项目中也能灵活运用这些技巧,解决实际问题。

本文完整案例:https://github.com/lenve/springboot3-samples/tree/main/file_upload

相关推荐
魔道不误砍柴功2 小时前
简单叙述 Spring Boot 启动过程
java·数据库·spring boot
失落的香蕉2 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
枫叶_v2 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
wclass-zhengge2 小时前
SpringCloud篇(配置中心 - Nacos)
java·spring·spring cloud
路在脚下@2 小时前
Springboot 的Servlet Web 应用、响应式 Web 应用(Reactive)以及非 Web 应用(None)的特点和适用场景
java·spring boot·servlet
黑马师兄2 小时前
SpringBoot
java·spring
数据小小爬虫2 小时前
如何用Java爬虫“偷窥”淘宝商品类目API的返回值
java·爬虫·php
暮春二十四2 小时前
关于用postman调用接口成功但是使用Java代码调用却失败的问题
java·测试工具·postman
杜杜的man2 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布3 小时前
Java中Properties的使用详解
java·开发语言·后端