Spring Boot 实现文件断点续传

文章首发公众号【风象南】

在处理大文件传输或网络不稳定的情况下,文件断点续传功能显得尤为重要。本文将详细介绍如何使用Spring Boot实现文件的断点续传功能,并提供完整的前后端代码实现。

一、断点续传技术原理

断点续传的核心原理是将文件分片传输并记录进度,主要包括

  • 客户端将大文件分割成小块逐一上传
  • 服务端保存已上传块信息
  • 传输中断后只需继续传未上传部分

二、服务端代码实现

项目依赖配置

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.7.18</version>
    </dependency>

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.1</version>
    </dependency>
</dependencies>

application.yaml配置

yaml 复制代码
file:
  upload:
    dir: D:/tmp
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 100MB
      max-request-size: 100MB
      file-size-threshold: 2KB

文件块信息实体类

typescript 复制代码
public class FileChunkDTO {
    /**
     * 当前文件块,从1开始
     */
    private Integer chunkNumber;
    
    /**
     * 分块大小
     */
    private Long chunkSize;
    
    /**
     * 当前分块大小
     */
    private Long currentChunkSize;
    
    /**
     * 总大小
     */
    private Long totalSize;
    
    /**
     * 文件标识
     */
    private String identifier;
    
    /**
     * 文件名
     */
    private String filename;
    
    /**
     * 相对路径
     */
    private String relativePath;
    
    /**
     * 总块数
     */
    private Integer totalChunks;

    public Integer getChunkNumber() {
        return chunkNumber;
    }

    public void setChunkNumber(Integer chunkNumber) {
        this.chunkNumber = chunkNumber;
    }

    public Long getChunkSize() {
        return chunkSize;
    }

    public void setChunkSize(Long chunkSize) {
        this.chunkSize = chunkSize;
    }

    public Long getCurrentChunkSize() {
        return currentChunkSize;
    }

    public void setCurrentChunkSize(Long currentChunkSize) {
        this.currentChunkSize = currentChunkSize;
    }

    public Long getTotalSize() {
        return totalSize;
    }

    public void setTotalSize(Long totalSize) {
        this.totalSize = totalSize;
    }

    public String getIdentifier() {
        return identifier;
    }

    public void setIdentifier(String identifier) {
        this.identifier = identifier;
    }

    public String getFilename() {
        return filename;
    }

    public void setFilename(String filename) {
        this.filename = filename;
    }

    public String getRelativePath() {
        return relativePath;
    }

    public void setRelativePath(String relativePath) {
        this.relativePath = relativePath;
    }

    public Integer getTotalChunks() {
        return totalChunks;
    }

    public void setTotalChunks(Integer totalChunks) {
        this.totalChunks = totalChunks;
    }
}

通用响应类

typescript 复制代码
public class FileUploadResponse {
    private boolean success;
    private String message;
    private Object data;

    public FileUploadResponse(boolean success, String message, Object data) {
        this.success = success;
        this.message = message;
        this.data = data;
    }

    public static FileUploadResponse success(String message, Object data) {
        return new FileUploadResponse(true, message, data);
    }
    
    public static FileUploadResponse success(String message) {
        return new FileUploadResponse(true, message, null);
    }
    
    public static FileUploadResponse error(String message) {
        return new FileUploadResponse(false, message, null);
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

文件上传服务

java 复制代码
import cn.hutool.core.io.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;

@Service
public class FileUploadService {

    private static final Logger logger = LoggerFactory.getLogger(FileUploadService.class);

    @Value("${file.upload.dir}")
    private String uploadDir;

    /**
     * 检查文件是否已上传过
     */
    public boolean checkFileExists(FileChunkDTO chunk) {
        String storeChunkPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier() + File.separator + chunk.getChunkNumber();
        File storeChunk = new File(storeChunkPath);
        return storeChunk.exists() && chunk.getChunkSize() == storeChunk.length();
    }

    /**
     * 上传文件块
     */
    public FileUploadResponse uploadChunk(FileChunkDTO chunk, MultipartFile file) {
        try {
            if (file.isEmpty()) {
                return FileUploadResponse.error("文件块为空");
            }
            
            // 创建块文件目录
            String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier();
            File chunkDir = new File(chunkDirPath);
            if (!chunkDir.exists()) {
                chunkDir.mkdirs();
            }
            
            // 保存分块
            String chunkPath = chunkDirPath + File.separator + chunk.getChunkNumber();
            file.transferTo(new File(chunkPath));
            
            return FileUploadResponse.success("文件块上传成功");
        } catch (IOException e) {
            logger.error(e.getMessage(),e);
            return FileUploadResponse.error("文件块上传失败: " + e.getMessage());
        }
    }

    /**
     * 合并文件块
     */
    public FileUploadResponse mergeChunks(String identifier, String filename, Integer totalChunks) {
        try {
            String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + identifier;
            if(!FileUtil.exist(chunkDirPath)){
                return FileUploadResponse.error("文件合并失败, 目录不存在" );
            }

            File chunkDir = new File(chunkDirPath);
            // 创建目标文件
            String filePath = uploadDir + File.separator + filename;
            File destFile = new File(filePath);
            if (destFile.exists()) {
                destFile.delete();
            }
            // 使用RandomAccessFile合并文件块
            try (RandomAccessFile randomAccessFile = new RandomAccessFile(destFile, "rw")) {
                byte[] buffer = new byte[1024];
                for (int i = 1; i <= totalChunks; i++) {
                    File chunk = new File(chunkDirPath + File.separator + i);
                    if (!chunk.exists()) {
                        return FileUploadResponse.error("文件块" + i + "不存在");
                    }
                    
                    try (java.io.FileInputStream fis = new java.io.FileInputStream(chunk)) {
                        int len;
                        while ((len = fis.read(buffer)) != -1) {
                            randomAccessFile.write(buffer, 0, len);
                        }
                    }
                }
            }
            
            // 清理临时文件块
            FileUtil.del(chunkDir);
            
            return FileUploadResponse.success("文件合并成功", filePath);
        } catch (IOException e) {
            logger.error(e.getMessage(),e);
            return FileUploadResponse.error("文件合并失败: " + e.getMessage());
        }
    }
}

控制器

less 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/upload")
@CrossOrigin // 允许跨域请求
public class FileUploadController {

    private static Logger logger = LoggerFactory.getLogger(FileDownloadService.class);

    @Autowired
    private FileUploadService fileUploadService;

    /**
     * 检查文件或文件块是否已存在
     */
    @GetMapping("/check")
    public ResponseEntity<Void> checkFileExists(FileChunkDTO chunk) {
        boolean exists = fileUploadService.checkFileExists(chunk);
        if (exists) {
            // 分片存在,返回 200
            return ResponseEntity.ok().build();
        } else {
            // 分片不存在,返回 404
            return ResponseEntity.notFound().build();
        }
    }

    /**
     * 上传文件块
     */
    @PostMapping("/chunk")
    public FileUploadResponse uploadChunk(
            @RequestParam(value = "chunkNumber") Integer chunkNumber,
            @RequestParam(value = "chunkSize") Long chunkSize,
            @RequestParam(value = "currentChunkSize") Long currentChunkSize,
            @RequestParam(value = "totalSize") Long totalSize,
            @RequestParam(value = "identifier") String identifier,
            @RequestParam(value = "filename") String filename,
            @RequestParam(value = "totalChunks") Integer totalChunks,
            @RequestParam("file") MultipartFile file) {

        String identifierName = identifier;
        // 如果 identifierName 包含逗号,取第一个值
        if (identifierName.contains(",")) {
            identifierName = identifierName.split(",")[0];
        }

        FileChunkDTO chunk = new FileChunkDTO();
        chunk.setChunkNumber(chunkNumber);
        chunk.setChunkSize(chunkSize);
        chunk.setCurrentChunkSize(currentChunkSize);
        chunk.setTotalSize(totalSize);
        chunk.setIdentifier(identifierName);
        chunk.setFilename(filename);
        chunk.setTotalChunks(totalChunks);

        return fileUploadService.uploadChunk(chunk, file);
    }

    /**
     * 合并文件块
     */
    @PostMapping("/merge")
    public FileUploadResponse mergeChunks(
            @RequestParam("identifier") String identifier,
            @RequestParam("filename") String filename,
            @RequestParam("totalChunks") Integer totalChunks) {
        return fileUploadService.mergeChunks(identifier, filename, totalChunks);
    }
}

三、前端实现

完整HTML页面

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件断点续传示例</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .upload-container, .download-container {
            margin-top: 30px;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .progress {
            margin-top: 10px;
            height: 25px;
        }
        .file-list {
            margin-top: 20px;
        }
        .file-item {
            padding: 10px;
            margin-bottom: 5px;
            border: 1px solid #eee;
            border-radius: 5px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
    </style>
</head>
<body>
<div class="container">
    <h1 class="mt-4 mb-4">文件断点续传示例</h1>

    <!-- 上传区域 -->
    <div class="upload-container">
        <h3>文件上传(支持断点续传)</h3>
        <div class="mb-3">
            <label for="fileUpload" class="form-label">选择文件</label>
            <input class="form-control" type="file" id="fileUpload">
        </div>
        <button id="uploadBtn" class="btn btn-primary">上传文件</button>
        <div class="progress d-none" id="uploadProgress">
            <div class="progress-bar" role="progressbar" style="width: 0%;" id="uploadProgressBar">0%</div>
        </div>
        <div id="uploadStatus" class="mt-2"></div>
    </div>

    <div class="download-container">
        <h3>文件列表</h3>
        <button id="refreshBtn" class="btn btn-secondary mb-3">刷新文件列表</button>
        <div id="fileList" class="file-list">
            <div class="alert alert-info">点击刷新按钮获取文件列表</div>
        </div>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/resumable.min.js"></script>

<script>
    // 基础配置
    const API_BASE_URL = 'http://localhost:8080/api';

    // DOM元素
    const uploadBtn = document.getElementById('uploadBtn');
    const fileUpload = document.getElementById('fileUpload');
    const uploadProgress = document.getElementById('uploadProgress');
    const uploadProgressBar = document.getElementById('uploadProgressBar');
    const uploadStatus = document.getElementById('uploadStatus');
    const refreshBtn = document.getElementById('refreshBtn');
    const fileList = document.getElementById('fileList');
    const downloadProgress = document.getElementById('downloadProgress');
    const downloadProgressBar = document.getElementById('downloadProgressBar');
    const downloadStatus = document.getElementById('downloadStatus');

    // 初始化resumable.js
    const resumable = new Resumable({
        target: `${API_BASE_URL}/upload/chunk`,
        query: {},
        chunkSize: 1 * 1024 * 1024, // 分片大小为1MB
        simultaneousUploads: 3,
        testChunks: true,
        throttleProgressCallbacks: 1,
        chunkNumberParameterName: 'chunkNumber',
        chunkSizeParameterName: 'chunkSize',
        currentChunkSizeParameterName: 'currentChunkSize',
        totalSizeParameterName: 'totalSize',
        identifierParameterName: 'identifier',
        fileNameParameterName: 'filename',
        totalChunksParameterName: 'totalChunks',
        method: 'POST',
        headers: {
            'Accept': 'application/json'
        },
        testMethod: 'GET',
        testTarget: `${API_BASE_URL}/upload/check`
    });

    // 分配事件监听器
    resumable.assignBrowse(fileUpload);

    // 文件添加事件 - 显示文件名
    resumable.on('fileAdded', function(file) {
        console.log('File added:', file);
        // 显示已选择的文件名
        uploadStatus.innerHTML = `<div class="alert alert-info">已选择文件: ${file.fileName} (${formatFileSize(file.size)})</div>`;

        // 显示文件信息卡片
        const fileInfoDiv = document.createElement('div');
        fileInfoDiv.className = 'card mt-2 mb-2';
        fileInfoDiv.innerHTML = `
            <div class="card-body">
                <h5 class="card-title">文件信息</h5>
                <p class="card-text">文件名: ${file.fileName}</p>
                <p class="card-text">大小: ${formatFileSize(file.size)}</p>
                <p class="card-text">类型: ${file.file.type || '未知'}</p>
                <p class="card-text">分片数: ${file.chunks.length}</p>
            </div>
        `;

        // 清除旧的文件信息
        const oldFileInfo = document.querySelector('.card');
        if (oldFileInfo) {
            oldFileInfo.remove();
        }

        // 插入到uploadStatus之前
        uploadStatus.parentNode.insertBefore(fileInfoDiv, uploadStatus);
    });

    // 格式化文件大小
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    // 分块开始事件
    resumable.on('chunkingStart', function(file) {
        console.log('开始分块:', file.fileName);
    });

    // 分块进度事件
    resumable.on('chunkingProgress', function(file, ratio) {
        console.log('分块进度:', Math.floor(ratio * 100) + '%');
    });

    // 分块完成事件
    resumable.on('chunkingComplete', function(file) {
        console.log('分块完成');
    });

    // 上传开始事件
    resumable.on('uploadStart', function() {
        console.log('开始上传');
        uploadStatus.innerHTML = '<div class="alert alert-info">开始上传文件块...</div>';
        window.mergeCalled = false;
    });

    // 上传进度事件
    resumable.on('fileProgress', function(file) {
        const progress = Math.floor(file.progress() * 100);
        uploadProgress.classList.remove('d-none');
        uploadProgressBar.style.width = `${progress}%`;
        uploadProgressBar.textContent = `${progress}%`;

        // 显示当前上传块信息
        const uploadedChunks = file.chunks.filter(chunk => chunk.status === 2).length;
        const totalChunks = file.chunks.length;
        uploadStatus.innerHTML = `<div class="alert alert-info">正在上传: ${uploadedChunks}/${totalChunks} 块 (${progress}%)</div>`;
    });

    // 总体进度事件
    resumable.on('progress', function() {
        console.log('总体进度:', Math.floor(resumable.progress() * 100) + '%');
    });

    // 上传成功事件
    resumable.on('fileSuccess', function(file, response) {
        console.log('文件上传成功,准备合并');
        const parsedResponse = JSON.parse(response);
        if (parsedResponse.success) {
            // 避免重复调用合并接口
            if (window.mergeCalled) {
                console.log('合并已经调用过,跳过');
                return;
            }
            window.mergeCalled = true;

            uploadStatus.innerHTML = '<div class="alert alert-info">所有分块上传成功,正在合并文件...</div>';

            // 使用FormData发送合并请求
            const formData = new FormData();
            formData.append('identifier', file.uniqueIdentifier);
            formData.append('filename', file.fileName);
            formData.append('totalChunks', file.chunks.length);

            axios.post(`${API_BASE_URL}/upload/merge`, formData)
                .then(function(response) {
                    if (response.data.success) {
                        uploadStatus.innerHTML = `<div class="alert alert-success">文件上传并合并成功!</div>`;
                        // 刷新文件列表
                        refreshFileList();
                    } else {
                        uploadStatus.innerHTML = `<div class="alert alert-danger">文件合并失败: ${response.data.message}</div>`;
                    }
                })
                .catch(function(error) {
                    uploadStatus.innerHTML = `<div class="alert alert-danger">合并请求出错: ${error.message}</div>`;
                });
        } else {
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传失败: ${parsedResponse.message}</div>`;
        }
    });

    // 块上传错误事件
    resumable.on('chunkUploadError', function(file, chunk, message) {
        console.error('块上传错误:', chunk.offset, message);
        uploadStatus.innerHTML = `<div class="alert alert-warning">块 ${chunk.offset+1}/${file.chunks.length} 上传失败,系统将重试</div>`;
    });

    // 上传错误事件
    resumable.on('fileError', function(file, response) {
        try {
            const parsedResponse = JSON.parse(response);
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${parsedResponse.message || '未知错误'}</div>`;
        } catch (e) {
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${response || '未知错误'}</div>`;
        }
    });

    // 点击上传按钮事件
    uploadBtn.addEventListener('click', function() {
        if (!resumable.files.length) {
            uploadStatus.innerHTML = '<div class="alert alert-warning">请先选择文件!</div>';
            return;
        }
        uploadStatus.innerHTML = '<div class="alert alert-info">开始上传...</div>';
        resumable.upload();
    });

    // 获取文件列表
    function refreshFileList() {
        axios.get(`${API_BASE_URL}/download/files`)
            .then(function(response) {
                if (response.data.length > 0) {
                    let html = '';
                    response.data.forEach(function(fileName) {
                        html += `
                            <div class="file-item">
                                <span>${fileName}</span>
                            </div>
                        `;
                    });
                    fileList.innerHTML = html;
                } else {
                    fileList.innerHTML = '<div class="alert alert-info">没有文件</div>';
                }
            })
            .catch(function(error) {
                fileList.innerHTML = `<div class="alert alert-danger">获取文件列表失败: ${error.message}</div>`;
            });
    }

    // 刷新按钮事件
    refreshBtn.addEventListener('click', refreshFileList);

    // 初始加载文件列表
    document.addEventListener('DOMContentLoaded', function() {
        refreshFileList();
    });
</script>

</body>
</html>

四、核心实现原理详解

文件分片 :使用Resumable.js将大文件分割成多个小块(默认1MB),每块单独上传

检查已上传部分 :上传前先调用/api/upload/check检查服务器已保存的分片

断点续传流程

  • 文件的唯一标识符由文件名和大小计算得出
  • 服务端在临时目录下按标识符创建文件夹存储分片
  • 上传完成后调用合并接口,服务端将分片按顺序合并

文件合并 :服务端使用RandomAccessFile实现高效文件合并

五、效果演示

上传文件一半后触发终止

再次上传文件

通过请求可以看到,红色的8个文件块 (32-39) 检查(check)失败后进行了上传(chunk),其他的已上传 (1-31) 块并没有重新上传。

最终结果

相关推荐
程序员岳焱9 分钟前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
后端·sql·mysql
爱编程的喵9 分钟前
深入理解JavaScript原型机制:从Java到JS的面向对象编程之路
java·前端·javascript
龚思凯15 分钟前
Node.js 模块导入语法变革全解析
后端·node.js
天行健的回响18 分钟前
枚举在实际开发中的使用小Tips
后端
on the way 12320 分钟前
行为型设计模式之Mediator(中介者)
java·设计模式·中介者模式
保持学习ing23 分钟前
Spring注解开发
java·深度学习·spring·框架
wuhunyu23 分钟前
基于 langchain4j 的简易 RAG
后端
techzhi24 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
酷爱码27 分钟前
Spring Boot 整合 Apache Flink 的详细过程
spring boot·flink·apache
异常君1 小时前
Spring 中的 FactoryBean 与 BeanFactory:核心概念深度解析
java·spring·面试