Java案例如何实现分片上传?

wen java案例 4

本文目录导读:

Java案例如何实现分片上传?

  1. 核心设计思路
  2. 前端代码(Vue/原生JS示例)
  3. 后端代码(Spring Boot)
  4. 关键注意事项(面试加分项)
  5. 生产环境增强建议
  6. 运行验证

这是一个非常经典的面试题和实际开发需求,分片上传的核心目的是解决大文件上传超时、失败重传成本高、内存溢出等问题。

下面我提供一个完整且具有生产参考价值的 Java 分片上传案例,包括前端(HTML+JS)后端(Spring Boot) 的核心代码。


核心设计思路

  1. 前端: 将文件切成固定大小(5MB)的块,为每个块生成唯一标识(如 fileMd5 + chunkIndex),并发或串行发送到后端。
  2. 后端: 接收每一个分片,暂存到服务器临时目录,当所有分片上传完成后,提供合并接口,按分片序号顺序合并成完整文件。
  3. 断点续传: 上传前先请求后端接口查询已上传的分片列表,前端跳过已上传的分片。

前端代码(Vue/原生JS示例)

这里以纯 HTML + JS 为例,使用 spark-md5 计算文件 MD5(用于标识文件)。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">分片上传示例</title>
    <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body>
    <input type="file" id="fileInput" />
    <button onclick="upload()">开始上传</button>
    <div id="progress"></div>
    <script>
        const chunkSize = 5 * 1024 * 1024; // 每个分片 5MB
        function upload() {
            const file = document.getElementById('fileInput').files[0];
            if (!file) return;
            // 1. 计算文件 MD5
            const spark = new SparkMD5.ArrayBuffer();
            const reader = new FileReader();
            reader.readAsArrayBuffer(file);
            reader.onload = (e) => {
                spark.append(e.target.result);
                const fileMd5 = spark.end();
                console.log('文件MD5:', fileMd5);
                // 2. 开始分片上传
                uploadChunks(file, fileMd5);
            };
        }
        async function uploadChunks(file, fileMd5) {
            const totalChunks = Math.ceil(file.size / chunkSize);
            // 先查询已上传的分片(断点续传)
            const uploadedChunks = await checkUploadedChunks(fileMd5);
            console.log('已上传分片:', uploadedChunks);
            for (let i = 0; i < totalChunks; i++) {
                // 如果该分片已上传,跳过
                if (uploadedChunks.includes(i)) {
                    continue;
                }
                const start = i * chunkSize;
                const end = Math.min(start + chunkSize, file.size);
                const chunk = file.slice(start, end);
                const formData = new FormData();
                formData.append('chunk', chunk);
                formData.append('index', i);
                formData.append('totalChunks', totalChunks);
                formData.append('fileMd5', fileMd5);
                formData.append('originalFilename', file.name);
                // 上传单个分片
                await fetch('/upload/chunk', {
                    method: 'POST',
                    body: formData
                });
                // 更新进度
                document.getElementById('progress').innerText = 
                    `上传进度: ${((i + 1) / totalChunks * 100).toFixed(2)}%`;
            }
            // 3. 所有分片上传完成后,发起合并请求
            await fetch('/upload/merge', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ fileMd5, originalFilename: file.name, totalChunks })
            });
            alert('上传完成!');
        }
        // 查询已有分片(用于断点续传)
        async function checkUploadedChunks(fileMd5) {
            const resp = await fetch(`/upload/chunks?fileMd5=${fileMd5}`);
            return await resp.json(); // 返回已上传的分片索引数组,[0,1,2]
        }
    </script>
</body>
</html>

后端代码(Spring Boot)

项目依赖(pom.xml)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

控制器(UploadController.java)

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.nio.file.*;
import java.util.*;
@RestController
@RequestMapping("/upload")
public class UploadController {
    // 分片临时存储目录
    @Value("${upload.chunk-dir:/tmp/upload/chunks/}")
    private String chunkDir;
    // 合并后的文件存储目录
    @Value("${upload.final-dir:/tmp/upload/final/}")
    private String finalDir;
    /**
     * 接收单个分片
     */
    @PostMapping("/chunk")
    public String uploadChunk(@RequestParam("chunk") MultipartFile chunk,
                              @RequestParam("index") int index,
                              @RequestParam("fileMd5") String fileMd5) throws IOException {
        // 创建该文件的临时分片目录
        Path chunkPath = Paths.get(chunkDir, fileMd5);
        if (!Files.exists(chunkPath)) {
            Files.createDirectories(chunkPath);
        }
        // 保存分片文件,命名规则:0, 1, 2, ...
        File targetFile = new File(chunkPath.toFile(), String.valueOf(index));
        chunk.transferTo(targetFile);
        return "chunk " + index + " uploaded";
    }
    /**
     * 查询已上传的分片索引列表(用于断点续传)
     */
    @GetMapping("/chunks")
    public List<Integer> getUploadedChunks(@RequestParam("fileMd5") String fileMd5) {
        List<Integer> list = new ArrayList<>();
        Path chunkPath = Paths.get(chunkDir, fileMd5);
        if (Files.exists(chunkPath)) {
            File[] files = chunkPath.toFile().listFiles();
            if (files != null) {
                for (File f : files) {
                    list.add(Integer.parseInt(f.getName()));
                }
            }
        }
        Collections.sort(list);
        return list;
    }
    /**
     * 合并所有分片
     */
    @PostMapping("/merge")
    public String mergeChunks(@RequestBody MergeRequest request) throws IOException {
        String fileMd5 = request.getFileMd5();
        String originalFilename = request.getOriginalFilename();
        int totalChunks = request.getTotalChunks();
        // 准备最终文件路径
        Path finalPath = Paths.get(finalDir);
        if (!Files.exists(finalPath)) {
            Files.createDirectories(finalPath);
        }
        // 生成最终文件名:使用MD5前缀防止冲突,保留原始后缀
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
        Path targetFile = finalPath.resolve(fileMd5 + suffix);
        // 如果最终文件已存在,直接返回
        if (Files.exists(targetFile)) {
            return "file already exists";
        }
        // 按顺序合并分片
        Path chunkPath = Paths.get(chunkDir, fileMd5);
        try (FileOutputStream fos = new FileOutputStream(targetFile.toFile())) {
            for (int i = 0; i < totalChunks; i++) {
                Path chunk = chunkPath.resolve(String.valueOf(i));
                if (Files.exists(chunk)) {
                    Files.copy(chunk, fos);
                } else {
                    throw new RuntimeException("Missing chunk: " + i);
                }
            }
        }
        // 合并成功后,删除临时分片目录
        deleteDirectory(chunkPath.toFile());
        return "merge success: " + targetFile.toString();
    }
    /**
     * 删除目录及内部文件
     */
    private void deleteDirectory(File file) {
        if (file.isDirectory()) {
            File[] files = file.listFiles();
            if (files != null) {
                for (File f : files) {
                    deleteDirectory(f);
                }
            }
        }
        file.delete();
    }
    /**
     * 合并请求体
     */
    static class MergeRequest {
        private String fileMd5;
        private String originalFilename;
        private int totalChunks;
        // getter setter(省略)
    }
}

配置文件(application.yml)

upload:
  chunk-dir: /data/tmp/upload/chunks/   # 分片临时目录
  final-dir: /data/tmp/upload/final/    # 最终文件目录

关键注意事项(面试加分项)

问题 解决方案
并发上传顺序错乱 后端不依赖顺序接收,合并时按序号读取
文件重复上传 前端传入 fileMd5,后端判断最终文件是否存在
服务端重启导致分片丢失 可用 Redis 记录分片状态,或直接检查磁盘文件
大文件 MD5 计算卡顿 前端使用 File.slice() 分片计算,或使用 Web Worker
传输安全性 分片文件名使用 UUID 避免越界,校验分片大小
异常处理 合并时缺少某分片,应抛出异常并提示前端重传该分片

生产环境增强建议

  1. 异步合并:使用消息队列,分片上传完成后发消息异步合并。
  2. 校验机制:每个分片上传时携带该分片的 MD5,后端校验完整性。
  3. 并发限流:对同一个 fileMd5 的分片上传请求使用令牌桶限制并发数。
  4. 存储分布式:分片临时文件可使用分布式文件系统(MinIO/OSS),最终合并可在对象存储中直接调用 Combine 接口(OSS 原生支持)。

运行验证

  1. 启动 Spring Boot 应用。
  2. 打开前端 HTML 页面,选择一个几百 MB 的大文件。
  3. 观察控制台和磁盘目录:
    • /tmp/upload/chunks/{fileMd5}/ 下出现多个数字命名的分片。
    • 上传完毕后,/tmp/upload/final/ 下生成完整文件。

这个案例覆盖了分片上传断点续传合并的核心逻辑,稍加改造即可直接用于真实项目。

抱歉,评论功能暂时关闭!