SpringBoot如何实现大文件夹上传的高效解决方案?

开发者日记:2023年11月25日 周六 阴
项目名称:跨平台大文件传输系统(WebUploader+Vue3+SpringBoot+腾讯云COS)


项目背景与核心挑战

作为河南独立开发者,近期承接了一个高复杂度外包项目,客户要求实现20G级文件/文件夹跨浏览器上传下载,技术栈涉及:

  • 前端:Vue3(TypeScript)+ WebUploader(兼容IE8)
  • 后端:SpringBoot 3.0 + Oracle 21c
  • 存储:腾讯云COS(需处理分片上传与合并)
  • 兼容性:从IE8到现代浏览器(Chrome/Firefox/Safari/Edge/Opera)

现存痛点

  1. 网上开源方案仅支持单文件上传,无完整文件夹层级解析逻辑
  2. IE8的ActiveX控件在Windows 11上频繁崩溃
  3. Oracle数据库与MySQL的序列化差异导致进度存储失败
  4. 20G文件传输时,SpringBoot默认超时时间(1分钟)严重不足

技术方案设计

前端架构(Vue3 + WebUploader)
graph TD
    A[用户选择文件/文件夹] --> B{浏览器类型}
    B -->|Chrome/Firefox| C[使用FileSystemDirectoryHandle API]
    B -->|IE8| D[调用ActiveX控件递归读取]
    C & D --> E[生成文件树结构(JSON)]
    E --> F[计算文件ETag(替代MD5,兼容IE8)]
    F --> G[分片上传(WebUploader)]
    G --> H[本地存储进度(IndexedDB+localStorage)]
后端架构(SpringBoot + Oracle)
graph TD
    A[接收分片] --> B{是否首片}
    B -->|是| C[创建Oracle任务记录(序列+BLOB)]
    B -->|否| D[更新分片进度(JDBC批处理)]
    C & D --> E[存储分片到临时目录]
    E --> F{是否全部分片完成}
    F -->|是| G[合并文件并上传COS]
    F -->|否| H[返回继续上传指令]
关键数据库设计(Oracle)
-- 上传任务表
CREATE TABLE UPLOAD_TASK (
    TASK_ID VARCHAR2(36) PRIMARY KEY,
    FILE_ETAG VARCHAR2(64) NOT NULL,
    RELATIVE_PATH VARCHAR2(1024), -- 保留文件夹层级(如 /project/src/)
    TOTAL_CHUNKS NUMBER,
    UPLOADED_CHUNKS NUMBER DEFAULT 0,
    STATUS VARCHAR2(20) CHECK (STATUS IN ('PENDING','UPLOADING','COMPLETED','FAILED')),
    COS_KEY VARCHAR2(1024),
    CREATE_TIME TIMESTAMP DEFAULT SYSTIMESTAMP
);

-- 分片存储表(Oracle BLOB优化)
CREATE TABLE UPLOAD_CHUNK (
    CHUNK_ID VARCHAR2(72) PRIMARY KEY, -- TASK_ID+CHUNK_INDEX
    TASK_ID VARCHAR2(36) REFERENCES UPLOAD_TASK(TASK_ID),
    CHUNK_INDEX NUMBER,
    CHUNK_DATA BLOB,
    CONSTRAINT UK_TASK_CHUNK UNIQUE (TASK_ID, CHUNK_INDEX)
);

核心代码实现

前端:文件夹上传与断点续传(Vue3 + TypeScript)
// src/components/FolderUploader.vue
import { ref, onMounted } from 'vue';
import WebUploader from 'webuploader';
import { calculateFileETag } from '@/utils/fileHash'; // 自定义ETag计算(兼容IE8)

export default {
  setup() {
    const taskList = ref>([]);
    const uploader = ref(null);

    // 初始化上传器(兼容IE8)
    const initUploader = () => {
      const isIE8 = document.documentMode === 8;
      
      uploader.value = WebUploader.create({
        swf: '/static/Uploader.swf', // IE8回退
        server: '/api/upload/chunk',
        chunked: true,
        chunkSize: isIE8 ? 4 * 1024 * 1024 : 20 * 1024 * 1024, // IE8限制4MB分片
        threads: isIE8 ? 1 : 5,
        formData: {
          taskId: localStorage.getItem('currentTaskId') || ''
        },
        timeout: 0 // 禁用超时(由后端控制)
      });

      // 恢复未完成任务(从IndexedDB)
      restoreTasksFromDB();
    };

    // 递归解析文件夹(跨浏览器)
    const handleFolderSelect = async (e: Event) => {
      const input = e.target as HTMLInputElement;
      const files = input.files;
      if (!files?.length) return;

      const parseFolder = async (entries: FileSystemEntry[], parentPath = '') => {
        for (let entry of entries) {
          if (entry.isFile) {
            const file = (entry as FileSystemFileEntry).file!;
            const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
            await addUploadTask(file, relativePath);
          } else if (entry.isDirectory) {
            const dirReader = (entry as FileSystemDirectoryEntry).createReader();
            const newEntries = await new Promise(resolve => {
              dirReader.readEntries(resolve);
            });
            await parseFolder(newEntries, parentPath ? `${parentPath}/${entry.name}` : entry.name);
          }
        }
      };

      // Chrome/Firefox使用showDirectoryPicker
      if (files[0].webkitGetAsEntry) {
        const entry = files[0].webkitGetAsEntry();
        if (entry?.isDirectory) {
          const dirReader = entry.createReader();
          const entries = await new Promise(resolve => {
            dirReader.readEntries(resolve);
          });
          await parseFolder(entries);
        } else {
          await addUploadTask(files[0], files[0].name);
        }
      }
      // IE8使用ActiveX(需用户授权)
      else if (window.ActiveXObject) {
        // ActiveX实现代码省略(需处理权限弹窗)
      }
    };

    // 添加上传任务
    const addUploadTask = async (file: File, relativePath: string) => {
      const etag = await calculateFileETag(file); // 使用CRC32+文件大小替代MD5
      const task = {
        file,
        relativePath,
        etag,
        chunkCount: Math.ceil(file.size / uploader.value!.options.chunkSize),
        uploadedChunks: 0
      };

      // 检查本地是否有未完成记录
      const existingTask = await getTaskFromDB(etag);
      if (existingTask) {
        task.uploadedChunks = existingTask.uploadedChunks;
      }

      taskList.value.push(task);
      saveTaskToDB(task);
      startUpload(task);
    };

    onMounted(() => {
      initUploader();
      document.getElementById('folderInput')?.addEventListener('change', handleFolderSelect);
    });

    return { taskList, uploader };
  }
};
后端:SpringBoot分片处理与COS上传
// src/main/java/com/example/uploader/controller/UploadController.java
@RestController
@RequestMapping("/api/upload")
public class UploadController {

    @Autowired
    private UploadTaskRepository taskRepository;

    @Autowired
    private UploadChunkRepository chunkRepository;

    @Autowired
    private COSClient cosClient; // 腾讯云COS客户端

    // 分片上传接口
    @PostMapping("/chunk")
    public ResponseEntity handleChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("taskId") String taskId,
            @RequestParam("chunkIndex") int chunkIndex,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("etag") String etag,
            @RequestParam("relativePath") String relativePath) {

        // 1. 查询或创建任务
        UploadTask task = taskRepository.findById(taskId)
                .orElseGet(() -> {
                    UploadTask newTask = new UploadTask();
                    newTask.setTaskId(taskId);
                    newTask.setFileEtag(etag);
                    newTask.setRelativePath(relativePath);
                    newTask.setTotalChunks(totalChunks);
                    newTask.setStatus("UPLOADING");
                    return taskRepository.save(newTask);
                });

        // 2. 保存分片到Oracle BLOB
        UploadChunk chunk = new UploadChunk();
        chunk.setChunkId(taskId + "_" + chunkIndex);
        chunk.setTaskId(taskId);
        chunk.setChunkIndex(chunkIndex);
        try {
            chunk.setChunkData(file.getBytes());
            chunkRepository.save(chunk);
        } catch (IOException e) {
            return ResponseEntity.status(500).body("分片保存失败");
        }

        // 3. 更新任务进度
        task.setUploadedChunks(chunkIndex + 1);
        taskRepository.save(task);

        // 4. 检查是否全部上传完成
        if (task.getUploadedChunks() >= task.getTotalChunks()) {
            // 启动异步合并任务
            mergeAndUploadToCOS(task);
            return ResponseEntity.ok("{\"status\":\"COMPLETED\"}");
        }

        return ResponseEntity.ok("{\"status\":\"SUCCESS\"}");
    }

    // 异步合并并上传COS
    @Async
    public void mergeAndUploadToCOS(UploadTask task) {
        try {
            // 1. 从Oracle读取所有分片
            List chunks = chunkRepository.findByTaskIdOrderByChunkIndex(task.getTaskId());
            
            // 2. 合并文件(流式处理避免内存溢出)
            Path tempFile = Files.createTempFile("upload_", ".tmp");
            try (OutputStream out = Files.newOutputStream(tempFile)) {
                for (UploadChunk chunk : chunks) {
                    out.write(chunk.getChunkData());
                }
            }

            // 3. 上传到腾讯云COS
            String cosKey = "uploads/" + task.getTaskId() + "/" + task.getRelativePath();
            cosClient.putObject(new PutObjectRequest("your-bucket", cosKey, tempFile.toFile()));

            // 4. 更新任务状态
            task.setStatus("COMPLETED");
            task.setCosKey(cosKey);
            taskRepository.save(task);

            // 5. 清理临时文件和分片
            Files.deleteIfExists(tempFile);
            chunkRepository.deleteByTaskId(task.getTaskId());
        } catch (Exception e) {
            task.setStatus("FAILED");
            taskRepository.save(task);
        }
    }
}

关键问题解决

  1. IE8兼容性

    • 使用标签替代已废弃的加载Uploader.swf
    • 通过document.execCommand('SaveAs')实现IE8的下载功能
  2. Oracle性能优化

    • UPLOAD_CHUNK表的TASK_ID字段建立索引
    • 使用JDBC批处理更新进度(addBatch()+executeBatch()
  3. 大文件传输超时

    • 在SpringBoot配置中增加:
      server:
        tomcat:
          connection-timeout: 0 # 禁用连接超时
      spring:
        servlet:
          multipart:
            max-file-size: 20GB
            max-request-size: 20GB
      
  4. 文件夹层级保留

    • 前端传递relativePath参数(如/docs/2023/report.pdf
    • 后端直接存储该路径,上传COS时保持原样

求助与社区支持

目前遇到以下难题,已在QQ群(374992201)发布详细日志:

  1. IE8的ActiveX控件在Windows 11上频繁弹出权限警告
  2. Oracle BLOB存储20G文件时出现ORA-01653表空间不足错误
  3. SpringBoot异步任务在合并大文件时被K8s容器终止

完整代码仓库

  • 前端:https://gitee.com/yourname/vue3-folder-uploader
  • 后端:https://gitee.com/yourname/springboot-cos-uploader

明日计划

  1. 实现WebUploader的IE8进度条显示
  2. 编写Oracle分片表的分区策略(按任务ID哈希分区)
  3. 测试20G文件在低带宽(2Mbps)下的传输稳定性

(日记结束)

附:技术栈对比表

模块原方案当前方案优化点
前端框架jQueryVue3 + TypeScript类型安全+组件化
上传组件WebUploader基础版定制版(支持文件夹+ETag)递归解析文件夹结构
后端语言JSPSpringBoot 3.0响应式编程+Oracle优化
数据库MySQLOracle 21cBLOB存储优化+分区表
对象存储百度OBS腾讯云COS适配不同的分片API规范

如需完整项目或调试协助,请联系QQ群或留言获取测试账号!

SQL示例

创建数据库

image

配置数据库连接

image

自动下载maven依赖

image

启动项目

image

启动成功

image

访问及测试

默认页面接口定义

image

在浏览器中访问

image

数据表中的数据

image

效果预览

文件上传

文件上传

文件刷新续传

支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传
文件续传

文件夹上传

支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。
文件夹上传

批量下载

支持文件批量下载
批量下载

下载续传

文件下载支持离线保存进度信息,刷新页面,关闭页面,重启系统均不会丢失进度信息。
下载续传

文件夹下载

支持下载文件夹,并保留层级结构,不打包,不占用服务器资源。
文件夹下载

示例下载

下载完整示例

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值