开发者

SpringBoot实现文件断点续传功能详解

目录
  • 一、断点续传技术原理
  • 二、服务端代码实现
    • 项目依赖配置
    • application.yaml配置
    • 文件块信息实体类
    • 通用响应类
    • 文件上传服务
    • 控制器
  • 三、前端实现
    • 四、核心实现原理详解
      • 五、效果演示

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

        一、断点续传技术原理

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

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

        二、服务端代码实现

        项目依赖配置

        <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配置

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

        文件块信息实体类

        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;
            }
        }
        

        通用响应类

        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;
            }
        }
        

        文件上传服务

        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}")
            privatwww.devze.come 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());
                }
            }
        }
        

        控制器

        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页面

        <!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/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="external nofollow"  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" 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/spark-md5@3.0.2/spark-md5.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/resumablejs@1.1.0/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.parejsntNode.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);
                upphploadStatus.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>`;
                    });
            }
        
            // 刷新按钮事件
            rphpefreshBtn.addEventListener('click', refreshFileList);
        
            // 初始加载文件列表
            document.addEventListener('DOMContentLoaded', function() {
                refreshFileList();
            });
        </script>
        
        </body>
        </html>
        

        四、核心实现原理详解

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

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

        断点续传流程

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

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

        五、效果演示

        SpringBoot实现文件断点续传功能详解

        上传文件一半后触发终止

        SpringBoot实现文件断点续传功能详解

        再次上传文件

        SpringBoot实现文件断点续传功能详解

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

        最终结果

        SpringBoot实现文件断点续传功能详解

        到此这篇关于SpringBoot实现文件断点续传功能详解的文章就介绍到这了,更多相关SpringBoot文件断点续传内容请搜索编程客栈(www.devze.com)以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程客栈(www.devze.com)!

        0

        上一篇:

        下一篇:

        精彩评论

        暂无评论...
        验证码 换一张
        取 消

        最新开发

        开发排行榜