Spring Boot大文件分块上传(代码篇)

发布于:2025-09-05 ⋅ 阅读:(26) ⋅ 点赞:(0)

背景介绍

        在现代Web应用开发中,文件上传是常见的功能需求,尤其是在处理图片、视频、文档等资源时。随着用户对多媒体内容需求的增加,上传文件的体积也越来越大,传统的单次上传方式在处理大文件时暴露出诸多问题。

直接上代码

后端代码

UploadController.java
package com.shenyun.lyguide.web;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;

@RestController
@RequestMapping("upload")
public class UploadController {

    // 文件存储的基本路径
    private static final String UPLOAD_DIR = "uploads/";
    
    // 定义分块大小(默认10MB)
    private static final long CHUNK_SIZE = 1024 * 1024 * 5;

    /**
     * 分块上传文件接口
     *
     * @param file     文件分块
     * @param chunkNumber 当前分块序号
     * @param totalChunks 总分块数
     * @param fileName    文件名
     * @return 上传结果
     */
    @PostMapping("/chunk")
    public Map<String, Object> uploadChunk(@RequestParam("file") MultipartFile file,
                                           @RequestParam("chunkNumber") Integer chunkNumber,
                                           @RequestParam("totalChunks") Integer totalChunks,
                                           @RequestParam("fileName") String fileName) {
        Map<String, Object> result = new HashMap<>();
        try {
            // 创建上传目录
            Path uploadPath = Paths.get(UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }

            // 创建临时目录存储分块文件
            Path tempDir = uploadPath.resolve("temp_" + fileName);
            if (!Files.exists(tempDir)) {
                Files.createDirectories(tempDir);
            }

            // 保存分块文件
            Path chunkPath = tempDir.resolve("chunk_" + chunkNumber);
            Files.write(chunkPath, file.getBytes());

            result.put("success", true);
            result.put("message", "分块上传成功");
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "分块上传失败: " + e.getMessage());
        }
        return result;
    }

    /**
     * 合并分块文件
     *
     * @param fileName 文件名
     * @param totalChunks 总分块数
     * @return 合并结果
     */
    @PostMapping("/merge")
    public Map<String, Object> mergeChunks(@RequestParam("fileName") String fileName,
                                           @RequestParam("totalChunks") Integer totalChunks) {
        Map<String, Object> result = new HashMap<>();
        try {
            Path uploadPath = Paths.get(UPLOAD_DIR);
            Path tempDir = uploadPath.resolve("temp_" + fileName);
            Path targetFile = uploadPath.resolve(fileName);

            // 创建目标文件
            try (OutputStream out = Files.newOutputStream(targetFile)) {
                // 按顺序合并分块文件
                for (int i = 0; i < totalChunks; i++) {
                    Path chunkPath = tempDir.resolve("chunk_" + i);
                    if (Files.exists(chunkPath)) {
                        Files.copy(chunkPath, out);
                    } else {
                        throw new RuntimeException("缺少分块文件: " + i);
                    }
                }
            }

            // 删除临时目录
            deleteDirectory(tempDir);

            result.put("success", true);
            result.put("message", "文件合并成功");
            result.put("filePath", targetFile.toString());
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "文件合并失败: " + e.getMessage());
        }
        return result;
    }

    /**
     * 检查分块是否已上传
     *
     * @param fileName 文件名
     * @param chunkNumber 分块序号
     * @return 检查结果
     */
    @GetMapping("/chunk/check")
    public Map<String, Object> checkChunk(@RequestParam("fileName") String fileName,
                                          @RequestParam("chunkNumber") Integer chunkNumber) {
        Map<String, Object> result = new HashMap<>();
        try {
            Path chunkPath = Paths.get(UPLOAD_DIR).resolve("temp_" + fileName).resolve("chunk_" + chunkNumber);
            boolean exists = Files.exists(chunkPath);
            result.put("exists", exists);
            result.put("success", true);
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "检查分块失败: " + e.getMessage());
        }
        return result;
    }

    /**
     * 删除目录及其内容
     *
     * @param directoryPath 目录路径
     * @throws IOException IO异常
     */
    private void deleteDirectory(Path directoryPath) throws IOException {
        if (Files.exists(directoryPath)) {
            Files.walk(directoryPath)
                    .sorted(Comparator.reverseOrder())
                    .map(Path::toFile)
                    .forEach(File::delete);
        }
    }
}

前端代码

resource/static/upload.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>大文件分块上传</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }
        .upload-container {
            border: 2px dashed #ccc;
            border-radius: 10px;
            padding: 20px;
            text-align: center;
            margin-bottom: 20px;
        }
        .upload-container.dragover {
            border-color: #007bff;
            background-color: #f8f9fa;
        }
        .file-input {
            margin: 20px 0;
        }
        .progress-container {
            margin: 20px 0;
        }
        .progress-bar {
            width: 100%;
            height: 20px;
            background-color: #f0f0f0;
            border-radius: 10px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            background-color: #007bff;
            width: 0%;
            transition: width 0.3s ease;
        }
        .chunk-info {
            margin: 10px 0;
            font-size: 14px;
        }
        .btn {
            background-color: #007bff;
            color: white;
            padding: 10px 20px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
        }
        .btn:hover {
            background-color: #0056b3;
        }
        .btn:disabled {
            background-color: #ccc;
            cursor: not-allowed;
        }
        .status {
            margin: 10px 0;
            padding: 10px;
            border-radius: 5px;
        }
        .status.success {
            background-color: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }
        .status.error {
            background-color: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }
    </style>
</head>
<body>
    <h1>大文件分块上传示例</h1>

    <div class="upload-container" id="dropZone">
        <p>拖拽文件到此处或点击选择文件</p>
        <input type="file" id="fileInput" class="file-input" style="display: none;">
        <button class="btn" onclick="document.getElementById('fileInput').click()">选择文件</button>
        <div class="file-info" id="fileInfo"></div>
    </div>

    <div class="chunk-settings">
        <label for="chunkSize">分块大小 (MB):</label>
        <input type="number" id="chunkSize" value="5" min="1" max="100">
    </div>

    <button class="btn" id="uploadBtn" onclick="startUpload()" disabled>开始上传</button>

    <div class="progress-container">
        <div class="chunk-info" id="chunkInfo"></div>
        <div class="progress-bar">
            <div class="progress-fill" id="progressFill"></div>
        </div>
        <div class="chunk-info" id="progressText">0%</div>
    </div>

    <div class="status" id="statusMessage" style="display: none;"></div>

    <script>
        let selectedFile = null;
        const dropZone = document.getElementById('dropZone');
        const fileInput = document.getElementById('fileInput');
        const fileInfo = document.getElementById('fileInfo');
        const uploadBtn = document.getElementById('uploadBtn');
        const chunkInfo = document.getElementById('chunkInfo');
        const progressFill = document.getElementById('progressFill');
        const progressText = document.getElementById('progressText');
        const statusMessage = document.getElementById('statusMessage');

        // 文件选择事件
        fileInput.addEventListener('change', function(e) {
            if (e.target.files.length > 0) {
                selectedFile = e.target.files[0];
                showFileInfo();
                uploadBtn.disabled = false;
            }
        });

        // 拖拽上传事件
        dropZone.addEventListener('dragover', function(e) {
            e.preventDefault();
            dropZone.classList.add('dragover');
        });

        dropZone.addEventListener('dragleave', function() {
            dropZone.classList.remove('dragover');
        });

        dropZone.addEventListener('drop', function(e) {
            e.preventDefault();
            dropZone.classList.remove('dragover');

            if (e.dataTransfer.files.length > 0) {
                selectedFile = e.dataTransfer.files[0];
                showFileInfo();
                uploadBtn.disabled = false;
            }
        });

        // 显示文件信息
        function showFileInfo() {
            const chunkSize = document.getElementById('chunkSize').value * 1024 * 1024;
            const totalChunks = Math.ceil(selectedFile.size / chunkSize);

            fileInfo.innerHTML = `
                <p><strong>文件名:</strong> ${selectedFile.name}</p>
                <p><strong>文件大小:</strong> ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB</p>
                <p><strong>分块数量:</strong> ${totalChunks}</p>
            `;
        }

        // 开始上传
        async function startUpload() {
            if (!selectedFile) {
                showMessage('请先选择文件', 'error');
                return;
            }

            const chunkSize = document.getElementById('chunkSize').value * 1024 * 1024;
            const totalChunks = Math.ceil(selectedFile.size / chunkSize);
            const fileName = selectedFile.name;

            uploadBtn.disabled = true;
            showMessage('开始上传...', 'success');

            let uploadedChunks = 0;

            // 分块上传文件
            for (let i = 0; i < totalChunks; i++) {
                // 检查分块是否已存在
                const checkResponse = await checkChunk(fileName, i);

                if (checkResponse.exists) {
                    // 分块已存在,跳过上传
                    uploadedChunks++;
                    updateProgress(uploadedChunks, totalChunks);
                    continue;
                }

                // 计算分块的起始和结束位置
                const start = i * chunkSize;
                const end = Math.min(start + chunkSize, selectedFile.size);
                const chunk = selectedFile.slice(start, end);

                // 创建FormData
                const formData = new FormData();
                formData.append('file', chunk, `${fileName}_chunk_${i}`);
                formData.append('chunkNumber', i);
                formData.append('totalChunks', totalChunks);
                formData.append('fileName', fileName);

                try {
                    // 上传分块
                    const response = await fetch('/upload/chunk', {
                        method: 'POST',
                        body: formData
                    });

                    const result = await response.json();

                    if (result.success) {
                        uploadedChunks++;
                        updateProgress(uploadedChunks, totalChunks);
                    } else {
                        throw new Error(result.message);
                    }
                } catch (error) {
                    showMessage(`上传分块 ${i} 失败: ${error.message}`, 'error');
                    uploadBtn.disabled = false;
                    return;
                }
            }

            // 所有分块上传完成,开始合并
            showMessage('所有分块上传完成,正在合并文件...', 'success');

            try {
                const response = await fetch('/upload/merge', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    body: `fileName=${encodeURIComponent(fileName)}&totalChunks=${totalChunks}`
                });

                const result = await response.json();

                if (result.success) {
                    showMessage(`文件上传成功!文件路径: ${result.filePath}`, 'success');
                } else {
                    throw new Error(result.message);
                }
            } catch (error) {
                showMessage(`合并文件失败: ${error.message}`, 'error');
            }

            uploadBtn.disabled = false;
        }

        // 检查分块是否存在
        async function checkChunk(fileName, chunkNumber) {
            const response = await fetch(`/upload/chunk/check?fileName=${encodeURIComponent(fileName)}&chunkNumber=${chunkNumber}`);
            return await response.json();
        }

        // 更新进度条
        function updateProgress(uploaded, total) {
            const percent = (uploaded / total) * 100;
            progressFill.style.width = percent + '%';
            progressText.textContent = percent.toFixed(2) + '%';
            chunkInfo.textContent = `已上传: ${uploaded}/${total} 个分块`;
        }

        // 显示状态消息
        function showMessage(message, type) {
            statusMessage.textContent = message;
            statusMessage.className = 'status ' + type;
            statusMessage.style.display = 'block';

            // 3秒后自动隐藏成功消息
            if (type === 'success') {
                setTimeout(() => {
                    statusMessage.style.display = 'none';
                }, 3000);
            }
        }
    </script>
</body>
</html>

注意点

如果分块的大小超过10MB,需要配置spring boot上传文件大小限制

# 文件上传相关配置
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

原理解释

接口功能解释


1、分块上传接口 (/upload/chunk):
接收文件分块、分块序号、总分块数和文件名
将每个分块保存到临时目录中

2、分块检查接口 (/upload/chunk/check):
检查指定分块是否已经上传,用于断点续传功能

3、合并分块接口 (/upload/merge):
将所有分块按顺序合并成完整文件
合并完成后删除临时分块文件

功能特性:

支持大文件分块上传
支持断点续传(通过检查分块接口)
自动合并分块文件
清理临时文件
 


网站公告

今日签到

点亮在社区的每一天
去签到