大文件分片上传完整案例

大文件分片上传:完整前后端代码


前端代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>大文件上传</title>
</head>
<body>
    <input type="file" id="fileInput" />
    <button onclick="upload()">上传</button>
    <div id="progress"></div>

    <script>
    const CHUNK_SIZE = 5 * 1024 * 1024; // 5M 一片

    async function upload() {
        const file = document.getElementById('fileInput').files[0];
        if (!file) return;

        const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
        const fileId = Date.now() + '-' + Math.random().toString(36).substring(2);

        // 计算文件的 MD5(秒传/校验用,大文件建议用 SparkMD5 增量计算)
        // 这里省略 MD5 计算,实际可以加上

        // 逐片上传说到底就是 for 循环
        for (let i = 0; i < totalChunks; i++) {
            const start = i * CHUNK_SIZE;
            const end = Math.min(start + CHUNK_SIZE, file.size);
            const chunk = file.slice(start, end);

            const formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('fileId', fileId);
            formData.append('chunkIndex', i);
            formData.append('totalChunks', totalChunks);
            formData.append('fileName', file.name);

            // 上传这一片
            const resp = await fetch('/upload/chunk', {
                method: 'POST',
                body: formData
            });
            const result = await resp.json();
            if (!result.success) {
                document.getElementById('progress').textContent = '上传失败,分片 ' + i;
                return;
            }

            // 更新进度
            const pct = Math.round(((i + 1) / totalChunks) * 100);
            document.getElementById('progress').textContent = pct + '%';
        }

        // 所有分片上传完成,通知后端合并
        const resp = await fetch('/upload/merge', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ fileId, fileName: file.name, totalChunks })
        });
        const result = await resp.json();
        alert('上传完成:' + result.filePath);
    }
    </script>
</body>
</html>

带并发控制的分片上传

如果一个个上传太慢,可以并发上传,但要注意控制并发数,不然浏览器会把连接占满。

html 复制代码
<script>
async function uploadWithConcurrency(file, maxConcurrent = 3) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const fileId = Date.now() + '-' + Math.random().toString(36).substring(2);

    // 把所有分片信息准备好
    const tasks = [];
    for (let i = 0; i < totalChunks; i++) {
        tasks.push(i);
    }

    let completed = 0;

    // 控制并发:一次只跑 maxConcurrent 个
    async function worker() {
        while (tasks.length > 0) {
            const i = tasks.shift();
            const start = i * CHUNK_SIZE;
            const end = Math.min(start + CHUNK_SIZE, file.size);
            const chunk = file.slice(start, end);

            const formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('fileId', fileId);
            formData.append('chunkIndex', i);
            formData.append('totalChunks', totalChunks);
            formData.append('fileName', file.name);

            const resp = await fetch('/upload/chunk', { method: 'POST', body: formData });
            const result = await resp.json();
            if (!result.success) throw new Error('分片 ' + i + ' 上传失败');

            completed++;
            const pct = Math.round((completed / totalChunks) * 100);
            document.getElementById('progress').textContent = pct + '%';
        }
    }

    // 启动 maxConcurrent 个 worker
    const workers = [];
    for (let i = 0; i < maxConcurrent; i++) {
        workers.push(worker());
    }
    await Promise.all(workers);

    // 合并
    const resp = await fetch('/upload/merge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileId, fileName: file.name, totalChunks })
    });
    return await resp.json();
}
</script>

后端代码

Controller 层

java 复制代码
@RestController
@RequestMapping("/upload")
public class UploadController {

    @Autowired
    private ChunkUploadService chunkUploadService;

    /**
     * 接收一个分片
     */
    @PostMapping("/chunk")
    public Result uploadChunk(@RequestParam("chunk") MultipartFile chunk,
                              @RequestParam("fileId") String fileId,
                              @RequestParam("chunkIndex") int chunkIndex,
                              @RequestParam("totalChunks") int totalChunks,
                              @RequestParam("fileName") String fileName) throws IOException {
        chunkUploadService.saveChunk(fileId, chunkIndex, chunk);
        return Result.success();
    }

    /**
     * 合并所有分片
     */
    @PostMapping("/merge")
    public Result mergeChunks(@RequestBody MergeRequest request) throws IOException {
        String filePath = chunkUploadService.merge(request.getFileId(),
                request.getFileName(),
                request.getTotalChunks());
        return Result.success(filePath);
    }
}

分片上传服务

java 复制代码
@Service
public class ChunkUploadService {

    /** 临时分片存放目录 */
    private static final String CHUNK_DIR = "/data/uploads/chunks/";

    /** 合并后的文件存放目录 */
    private static final String DEST_DIR = "/data/uploads/files/";

    /**
     * 保存一个分片到临时目录
     */
    public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException {
        // 每个文件一个文件夹,存放它的所有分片
        Path chunkDir = Paths.get(CHUNK_DIR, fileId);
        Files.createDirectories(chunkDir);

        // 分片文件命名:0、1、2、3...
        Path chunkFile = chunkDir.resolve(String.valueOf(chunkIndex));

        try (FileChannel out = FileChannel.open(chunkFile,
                StandardOpenOption.WRITE,
                StandardOpenOption.CREATE,
                StandardOpenOption.TRUNCATE_EXISTING);
             FileChannel in = (FileChannel) chunk.getInputStream().getChannel()) {

            long transferred = 0;
            long fileSize = in.size();
            while (transferred < fileSize) {
                transferred += in.transferTo(transferred, fileSize - transferred, out);
            }
        }
    }

    /**
     * 合并所有分片
     */
    public String merge(String fileId, String fileName, int totalChunks) throws IOException {
        Path chunkDir = Paths.get(CHUNK_DIR, fileId);
        Path destFile = Paths.get(DEST_DIR, System.currentTimeMillis() + "_" + fileName);
        Files.createDirectories(destFile.getParent());

        try (FileChannel out = FileChannel.open(destFile,
                StandardOpenOption.WRITE,
                StandardOpenOption.CREATE,
                StandardOpenOption.TRUNCATE_EXISTING)) {

            // 按照分片顺序,一个一个写进去
            for (int i = 0; i < totalChunks; i++) {
                Path chunkFile = chunkDir.resolve(String.valueOf(i));
                if (!Files.exists(chunkFile)) {
                    throw new IOException("分片丢失:" + i);
                }

                try (FileChannel in = FileChannel.open(chunkFile, StandardOpenOption.READ)) {
                    long transferred = 0;
                    long fileSize = in.size();
                    while (transferred < fileSize) {
                        transferred += in.transferTo(transferred, fileSize - transferred, out);
                    }
                }
            }
        }

        // 合并完删除临时分片目录
        deleteChunkDir(chunkDir);

        return destFile.toString();
    }

    private void deleteChunkDir(Path chunkDir) throws IOException {
        try (Stream<Path> files = Files.list(chunkDir)) {
            files.forEach(path -> {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException ignored) {}
            });
        }
        Files.deleteIfExists(chunkDir);
    }
}

DTO

java 复制代码
@Data
public class MergeRequest {
    private String fileId;
    private String fileName;
    private int totalChunks;
}

@Data
public class Result {
    private boolean success = true;
    private String filePath;

    public static Result success() {
        return new Result();
    }

    public static Result success(String filePath) {
        Result r = new Result();
        r.filePath = filePath;
        return r;
    }
}

用到的关键点

前端

  • file.slice(start, end) --- 切分文件,不占额外内存
  • FormData --- 传二进制分片,不需要 Base64 编码
  • 并发控制 --- 用 worker 模式限制并发数,不要一次性全发出去

后端

  • FileChannel.transferTo --- 零拷贝写分片文件和合并
  • 临时分片以 fileId/分片序号 组织,天然有序
  • 合并完清理临时目录

容错

实际生产还需要补充:

  • 断点续传 --- 上传前先请求 /upload/check?fileId=xxx,后端返回已收到的分片列表,前端跳过这些分片
  • MD5 校验 --- 前端计算文件 MD5,合并后后端校验是否一致
  • 超时清理 --- 定时任务清理超过一定时间未合并的临时分片目录
  • 分片大小自适应 --- 根据网络情况动态调整分片大小(但这个一般不需要,固定 5M 就挺好)
相关推荐
kuro-shiro1 小时前
SpringBoot 启动流程
java·spring boot·后端
吴声子夜歌1 小时前
SQL进阶——EXISTS谓词
java·数据库·sql
偏爱自由 !1 小时前
8. 泛型程序设计
java·开发语言·windows
剑挑星河月1 小时前
35.搜索插入位置
java·数据结构·算法·leetcode
海兰1 小时前
【SpringBoot 】AOP企业级权限控制方案(二)
android·java·spring boot
偏爱自由 !1 小时前
2:IDEA中git的使用--基础操作
java·git·intellij-idea
ch.ju1 小时前
Java Programming Chapter 4——Class loading
java·开发语言
LiaoWL1231 小时前
【SpringBoot合集-03】Spring Boot 启动过程学习
java·spring boot·学习
孟浩浩3 小时前
JAVA SpringAI+阿里云百炼应用开发
java·开发语言·阿里云