Spring Boot 中实现大文件分片上传与高效下载

Spring Boot 大文件分片上传与高效下载:一场速度与激情的邂逅

各位看官,大家好!今天咱们来聊聊一个既刺激又实用的话题:Spring Boot 如何实现大文件分片上传与高效下载。 这年头,谁还没见过几个G的文件呢? 想象一下,你辛辛苦苦拍了一部高清爱情动作片(咳咳,我说的是风景片!),想上传到云盘和朋友们分享,结果传了半天,进度条纹丝不动,最后还提示“网络错误,上传失败”。 这种感觉,是不是像便秘一样难受?

别慌!今天我就带你用Spring Boot,打造一个健步如飞、稳如泰山的大文件上传下载系统,让你的文件传输体验像丝般顺滑!

一、为什么需要分片上传?

在深入代码之前,咱们先来唠唠嗑,搞清楚为什么要用分片上传。

  • 解决网络不稳定问题: 大文件上传过程中,一旦网络中断,所有的努力都付诸东流,还得重头再来。 分片上传就好比把一个大任务分解成多个小任务,每次只上传一小块,即使网络中断,也只需要重传失败的那一块,大大提高了上传成功率。
  • 突破上传大小限制: 有些服务器或云存储平台对上传的文件大小有限制,分片上传可以将大文件分割成多个小文件,绕过这些限制。
  • 优化用户体验: 分片上传可以显示更精确的上传进度,让用户心里更有数,不会傻傻地等待。
  • 支持断点续传: 分片上传可以记录已经上传的分片,下次上传时可以从上次中断的地方继续,省时省力。

二、分片上传的实现思路

分片上传的核心思想很简单:

  1. 分割文件: 将大文件分割成多个大小相等(或最后一个分片略小)的小文件,每个小文件称为一个分片。
  2. 并行上传: 将这些分片并行上传到服务器。
  3. 合并分片: 服务器接收到所有分片后,按照正确的顺序将它们合并成完整的文件。

三、Spring Boot 实战:分片上传

好了,废话不多说,直接上代码!

1. 项目搭建

首先,我们需要创建一个Spring Boot项目。 使用Spring Initializr (https://start.spring.io/),选择以下依赖

  • Spring Web
  • Spring Boot DevTools (可选,方便开发)

2. 前端代码 (HTML + JavaScript)

前端负责将文件分割成片,并调用后端的接口进行上传。 这里使用 JavaScript 的 FileReader API 来读取文件内容,并使用 XMLHttpRequest 对象来发送请求。

<!DOCTYPE html>
<html>
<head>
    <title>大文件分片上传</title>
</head>
<body>
    <h1>大文件分片上传</h1>
    <input type="file" id="fileInput">
    <button onclick="uploadFile()">上传</button>
    <div id="progress">上传进度:0%</div>

    <script>
        const chunkSize = 1024 * 1024; // 每个分片的大小,1MB
        const uploadUrl = '/upload'; // 后端上传接口

        async function uploadFile() {
            const fileInput = document.getElementById('fileInput');
            const file = fileInput.files[0];

            if (!file) {
                alert('请选择文件');
                return;
            }

            const fileSize = file.size;
            const fileName = file.name;
            const chunkCount = Math.ceil(fileSize / chunkSize);

            for (let i = 0; i < chunkCount; i++) {
                const start = i * chunkSize;
                const end = Math.min(fileSize, start + chunkSize);
                const chunk = file.slice(start, end);

                await uploadChunk(chunk, i, chunkCount, fileName);
            }

            alert('文件上传完成!');
        }

        function uploadChunk(chunk, chunkIndex, chunkCount, fileName) {
            return new Promise((resolve, reject) => {
                const xhr = new XMLHttpRequest();
                xhr.open('POST', uploadUrl, true);
                xhr.setRequestHeader('Content-Type', 'application/octet-stream');
                xhr.setRequestHeader('X-File-Name', fileName);
                xhr.setRequestHeader('X-Chunk-Index', chunkIndex);
                xhr.setRequestHeader('X-Chunk-Count', chunkCount);

                xhr.upload.onprogress = (event) => {
                    if (event.lengthComputable) {
                        const percent = Math.round((chunkIndex / chunkCount + event.loaded / event.total / chunkCount) * 100);
                        document.getElementById('progress').innerText = `上传进度:${percent}%`;
                    }
                };

                xhr.onload = () => {
                    if (xhr.status === 200) {
                        resolve();
                    } else {
                        reject(new Error(`上传失败,状态码:${xhr.status}`));
                    }
                };

                xhr.onerror = () => {
                    reject(new Error('上传失败,网络错误'));
                };

                xhr.send(chunk);
            });
        }
    </script>
</body>
</html>

3. 后端代码 (Spring Boot)

后端负责接收分片,并将它们临时存储起来,最后合并成完整的文件。

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

@RestController
public class UploadController {

    private static final String UPLOAD_DIR = "uploads"; // 文件上传目录

    @PostMapping("/upload")
    public ResponseEntity<?> uploadChunk(HttpServletRequest request, @RequestBody byte[] chunk) {
        String fileName = request.getHeader("X-File-Name");
        int chunkIndex = Integer.parseInt(request.getHeader("X-Chunk-Index"));
        int chunkCount = Integer.parseInt(request.getHeader("X-Chunk-Count"));

        if (fileName == null || fileName.isEmpty()) {
            return ResponseEntity.badRequest().body("文件名不能为空");
        }

        try {
            // 创建上传目录
            Path uploadPath = Paths.get(UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }

            // 临时文件路径
            Path tempFilePath = Paths.get(UPLOAD_DIR, fileName + "_" + chunkIndex + ".part");

            // 写入分片数据
            Files.write(tempFilePath, chunk);

            // 如果是最后一个分片,合并文件
            if (chunkIndex == chunkCount - 1) {
                mergeChunks(fileName, chunkCount);
            }

            return ResponseEntity.ok().body("分片上传成功");

        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("分片上传失败: " + e.getMessage());
        }
    }

    private void mergeChunks(String fileName, int chunkCount) throws IOException {
        Path finalFilePath = Paths.get(UPLOAD_DIR, fileName);
        try (RandomAccessFile finalFile = new RandomAccessFile(finalFilePath.toFile(), "rw")) {
            for (int i = 0; i < chunkCount; i++) {
                Path chunkFilePath = Paths.get(UPLOAD_DIR, fileName + "_" + i + ".part");
                byte[] chunkData = Files.readAllBytes(chunkFilePath);
                finalFile.seek(i * chunkData.length); // 确保写入位置正确
                finalFile.write(chunkData);
                Files.deleteIfExists(chunkFilePath); // 删除临时分片文件
            }
        }
    }
}

4. 解释代码

  • 前端
    • chunkSize: 定义了每个分片的大小为 1MB。
    • uploadFile(): 从文件输入框获取文件,计算分片数量,然后循环调用 uploadChunk() 函数上传每个分片。
    • uploadChunk(): 使用 XMLHttpRequest 对象发送 POST 请求到 /upload 接口。 设置请求头,包括文件名 (X-File-Name),分片索引 (X-Chunk-Index) 和分片总数 (X-Chunk-Count)。 使用 FileReader 读取分片内容,并通过 send() 方法发送。 监听 upload.onprogress 事件来更新上传进度。
  • 后端
    • @PostMapping("/upload"): 处理文件上传请求。 从请求头中获取文件名、分片索引和分片总数。
    • UPLOAD_DIR: 定义了文件上传的目录。
    • Files.write(tempFilePath, chunk): 将分片数据写入临时文件。
    • mergeChunks(): 当所有分片都上传完毕后,将它们合并成一个完整的文件。 使用 RandomAccessFile 来实现高效的文件写入。 循环读取每个分片的内容,并将它们写入最终的文件。 最后,删除临时分片文件。

四、Spring Boot 实战:高效下载

上传搞定了,接下来就是下载了。 高效下载的关键在于:

  • Range 请求: 客户端可以指定需要下载的文件范围,服务器只返回指定范围的数据,避免传输整个文件。
  • 流式输出: 服务器将文件内容以流的形式输出,而不是一次性加载到内存中,可以有效降低内存占用。

1. 后端代码 (Spring Boot)

import org.springframework.core.io.FileSystemResource;
import org.springframework.http.*;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

@Controller
public class DownloadController {

    private static final String UPLOAD_DIR = "uploads"; // 文件上传目录

    @GetMapping("/download/{fileName}")
    @ResponseBody
    public ResponseEntity<FileSystemResource> downloadFile(@PathVariable String fileName,
                                                         @RequestHeader(value = "Range", required = false) String range) throws IOException {

        Path filePath = Paths.get(UPLOAD_DIR, fileName);
        File file = filePath.toFile();

        if (!file.exists()) {
            return ResponseEntity.notFound().build();
        }

        long fileSize = file.length();
        long start = 0;
        long end = fileSize - 1;

        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE);
        headers.add("Content-Disposition", "attachment; filename="" + fileName + """);
        headers.add("Accept-Ranges", "bytes");

        if (range != null && range.startsWith("bytes=")) {
            String[] ranges = range.substring("bytes=".length()).split("-");
            try {
                start = Long.parseLong(ranges[0]);
                if (ranges.length > 1) {
                    end = Long.parseLong(ranges[1]);
                }
            } catch (NumberFormatException e) {
                start = 0;
                end = fileSize - 1;
            }

            if (start < 0) {
                start = 0;
            }
            if (end >= fileSize) {
                end = fileSize - 1;
            }

            long contentLength = end - start + 1;
            headers.add("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
            headers.setContentLength(contentLength);
            return new ResponseEntity<>(new FileSystemResource(file), headers, HttpStatus.PARTIAL_CONTENT);
        } else {
            headers.setContentLength(fileSize);
            return new ResponseEntity<>(new FileSystemResource(file), headers, HttpStatus.OK);
        }
    }
}

2. 解释代码

  • @GetMapping("/download/{fileName}"): 处理文件下载请求。 @PathVariable 注解用于获取文件名。 @RequestHeader(value = "Range", required = false) 注解用于获取 Range 请求头。
  • FileSystemResource: Spring 提供的资源访问类,可以方便地读取文件内容。
  • Range 请求头: 用于指定需要下载的文件范围。 格式为 bytes=start-end,其中 startend 分别表示起始位置和结束位置。
  • Content-Range 响应头: 用于告知客户端实际返回的文件范围。 格式为 bytes start-end/fileSize
  • HttpStatus.PARTIAL_CONTENT: 表示服务器只返回了部分内容。
  • HttpStatus.OK: 表示服务器返回了完整的文件。

3. 前端代码 (HTML + JavaScript)

<!DOCTYPE html>
<html>
<head>
    <title>大文件下载</title>
</head>
<body>
    <h1>大文件下载</h1>
    <a href="/download/example.txt">下载 example.txt</a>

</body>
</html>

五、一些重要的注意事项

  • 安全性: 对于上传的文件,一定要进行安全检查,防止恶意文件上传。 可以检查文件类型、大小、内容等。
  • 存储: 可以选择将文件存储在本地磁盘、云存储服务 (如 Amazon S3, Azure Blob Storage, 阿里云 OSS 等)。
  • 性能优化:
    • 使用多线程或异步任务来处理文件上传和下载,提高并发能力。
    • 使用 CDN 加速下载,提高下载速度。
    • 对于频繁访问的文件,可以使用缓存。
  • 错误处理: 完善的错误处理机制可以提高系统的健壮性。 例如,捕获文件上传和下载过程中的异常,并进行适当的处理。
  • 断点续传: 实现断点续传功能,可以提高用户体验。 需要在后端记录已经上传的分片信息,并在下次上传时从上次中断的地方继续。

六、总结

通过以上步骤,我们就实现了一个简单的 Spring Boot 大文件分片上传与高效下载系统。 当然,这只是一个基础示例,实际应用中还需要考虑更多的因素,如安全性、性能优化、错误处理等。

希望这篇文章能够帮助你更好地理解 Spring Boot 大文件分片上传与高效下载的原理和实现方式。 记住,技术是为人民服务的,我们要用技术创造更美好的生活! 祝大家编码愉快,bug 远离!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注