SpringBoot+Vue实现大文件上传(断点续传-后端控制(一))

news2024/9/23 7:20:27

SpringBoot+Vue实现大文件上传(断点续传)

1 环境 SpringBoot 3.2.1,Vue 2,ElementUI,spark-md5
2 问题 在前一篇文章,我们写了通过在前端控制的断点续传,但是有两个问题,第一个问题:如果上传过程中,页面意外关闭或者其他原因,导致上传者不知道该文件是否上传成功,则会重复上传;第二个问题,我们将文件分片后,如果分片较多,我们一个一个的上传文件块,效率还是比较低。
3方案 基于前面的问题分析,我们可以将部分判断改到后端。针对第一个问题,我们可以保存每个分片的信息,如果下次再上传相同的文件时发现文件已存在且分片全部上传时,则可直接跳过,存在分片未全部上传时,返回未上传的分片下标;第二个问题,我们前端不再采用异步上传,而是多个分片同时上传,可以较高提升上传速度。本文我们先看下第一个问题怎么解决。

效果图
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
这里我们计算文件MD5值用的是spark-md5,首先需要在控制台执行安装命令:npm install --save spark-md5
然后在需要用的文件里引入:import SparkMD5 from "spark-md5";

前端代码
前端主要做的就是计算文件md5值,跟后端交互查询文件是否已上传,再根据情况将未上传的分片上传。

<template>
  <div class="container">
    <el-upload
        class="upload-demo"
        drag
        multiple
        action="/xml/fileUpload"
        :on-change="handleChange"
        :auto-upload="false">
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
      <div class="clearfix"></div>

      <div class="el-upload__tip">
        <el-progress :style="{ width: percentage + '%' }" :text-inside="true"
                     :stroke-width="24"
                     :percentage="percentage" :status="uploadStatus"></el-progress>
      </div>
    </el-upload>
    <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">上传到服务器</el-button>
  </div>
</template>

<script>
import axios from "axios";
import SparkMD5 from "spark-md5";

export default {
  name: 'App',
  data() {
    return {
      file: '',
      fileList: [],
      CHUNK_SIZE: 1024 * 1024 * 5,//100MB
      percentage: 0,
      chunkNo: 0,
      uploadStatus: ''
    }
  },
  watch: {},
  created() {
  },
  methods: {
    async fileHash(file) {
      // 1 第一种 计算文件的md5值,可以基于文件的一些基本属性来计算,不过这个存在的问题很明显,就是如果改了内容而文件大小不变的情况下,算出来的md5是一样的,优点就是计算速度快
      // const fileName = file.name
      // const fileType = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length);
      // const fileSize = file.size
      // console.log(SparkMD5.hash(fileName + fileType + fileSize))
      //--------------------------------------------------------------------
      // 2 第二种读取文件内容来计算,这种方式的优点就是只要文件内容改了算出来的md5值就不一样,缺点就是如果文件太大,一次性读到内存中计算的话会占内存,可能造成卡顿,计算速度相对较慢,
      // 我们可以增量计算,先计算第一个文件块的hash值,再将这个值和第二个文件块一起计算,如此下去,最终获取整个文件的hash,这样每次只读取一个文件块到内存中
      //注意此处不能直接在循环里写读取文件,那样会报错:Uncaught (in promise) DOMException: Failed to execute 'readAsArrayBuffer' on 'FileReader': The object is already busy reading Blobs.
      // 原因:因为每次循环中很快地连续调用 readAsArrayBuffer ,可能上一次的读取还未完成,新的调用就来了,导致冲突。
      //下面两种写法,一种是封装成异步的,一种是递归调用
      const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
      const spark = new SparkMD5();
      for (let i = 0; i < totalChunks; i++) {
        await new Promise((resolve) => {
          const start = i * this.CHUNK_SIZE;
          const end = Math.min(start + this.CHUNK_SIZE, file.size);
          const chunk = file.slice(start, end);

          const fileReader = new FileReader();
          // reader.onload 是为 FileReader 对象的 load 事件添加一个回调函数。当文件读取完成后,这个回调会被触发。
          // e 是事件对象。
          // e.target.result 获取到的就是读取文件后得到的结果数据,在这里是一个 ArrayBuffer 对象,它包含了文件的二进制数据。
          // 将 reader.onload 的处理逻辑写在读取文件操作之前
          // 这样做是为了提前定义好当文件读取完成这个事件发生时要执行的具体动作。在执行 reader.readAsArrayBuffer(file) 开始读取文件后,一旦读取完成,就会触发 onload 事件,从而执行之前定义好的回调函数。
          // 如果把这个处理逻辑放在后面,可能会导致在需要使用读取结果时,还没有正确地设置好处理的方式。
          // 先设置好回调,再触发相关操作,能确保整个流程的逻辑顺序和正确性。
          fileReader.onload = (e) => {
            //读取的字节数组
            const bytes = e.target.result;
            //增量计算
            spark.append(bytes);
            // resolve() 用于在异步操作完成时通知 Promise 状态变为已完成(fulfilled)
            //当文件读取的 onload 事件触发,表示当前这一块数据读取完成,此时调用 resolve() 来让等待这个 Promise 的后续代码知道可以继续进行下一步操作了。这样就实现了对异步读取过程的有序控制。
            resolve();
          };
          //读取文件块内容
          fileReader.readAsArrayBuffer(chunk);
        });
      }
      return spark.end()
      //---------------------------------------------------------------------------------
      //递归调用的写法
      // return new Promise((resolve) => {
      //   const spark = new SparkMD5();
      //   const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
      //
      //   function hash(index, CHUNK_SIZE) {
      //     if (index >= totalChunks) {
      //       //返回最终的结果 在调用的地方获取值:const result = await hash(); result 就是最后的md5值
      //       resolve(spark.end());
      //       return
      //     }
      //     const start = index * CHUNK_SIZE;
      //     const end = Math.min(start + CHUNK_SIZE, file.size);
      //     const chunk = file.slice(start, end);
      //     const read = new FileReader();
      //     read.onload = (e) => {
      //       //读取的字节数组
      //       const bytes = e.target.result
      //       spark.append(bytes)
      //       //递归调用
      //       hash(index + 1,)
      //     }
      //     read.readAsArrayBuffer(chunk)
      //   }
      //
      //   //开始第一次计算
      //   hash(0, this.CHUNK_SIZE)
      // })
    },
    async submitUpload() {
      //获取上传的文件信息
      const file = this.fileList[0].raw
      //生成md5值
      const md5 = await this.fileHash(file)
      console.log("文件MD5值:" + md5)
      const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);

      let startIndex = 0;
      const res = await axios.get('/xml/checkMD5?md5='+md5+'&totalChunks='+totalChunks)
      if(res.data.code === 200){
        if(res.data.data.startIndex<0){
          this.percentage = 100
          this.$message({
            message: '文件已上传!',
            type: 'warning'
          });
          return
        }
        startIndex = res.data.data.startIndex
        this.percentage = Math.ceil(startIndex / totalChunks * 100)
      }else {
        this.$message({
          message: '上传失败,请重试!',
          type: 'error'
        });
        return
      }
      //分片
      this.uploadStatus = 'success'
      for (let i = startIndex; i < totalChunks; i++) {
        const start = i * this.CHUNK_SIZE;
        const end = Math.min(start + this.CHUNK_SIZE, file.size);
        //将文件切片
        const chunk = file.slice(start, end);
        //组装参数
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileName', file.name);
        formData.append('md5', md5);
        formData.append('index', i);
        formData.append('status', 0);
        try {
          const res = await axios.post('/xml/bigFileUpload', formData)
          if (res.data.code === 200) {
            this.percentage = Math.ceil((i + 1) / totalChunks * 100)
            this.chunkNo = i + 1
          } else {
            this.$message({
              message: '上传失败',
              type: 'error'
            });
            this.errText = '失败'
            this.uploadStatus = 'exception'
            return
          }
        } catch (err) {
          console.log(err);
          this.$message.error('上传失败');
          this.uploadStatus = 'exception'
          return
        }
      }
      //调用合并分片请求
      await fetch('/xml/merge', {
        method: 'POST',
        body: JSON.stringify({fileName: file.name}),
        headers: {'Content-Type': 'application/json'}
      });
      this.$message({
        message: '文件上传成功!',
        type: 'success'
      });
    },
    handleChange(file, fileList) {
      this.fileList = fileList
    },
  }
}
</script>

<style>
.container {
  display: flex;
}

.progress-bar {
  position: absolute;
  height: 100%;
  background-color: #03f80d;
  transition: width 0.5s ease; /* 平滑过渡效果 */
}

.progress-number {
  position: absolute;
  right: 5px;
  top: 0;
  color: white;
  transition: opacity 0.5s ease; /* 文字的平滑过渡效果 */
}
</style>

后端代码

后端代码相较之前的多了一步,就是在分片上传时保存分片的一些信息。
如整个文件的md5值、文件名称、文件类型、分片导入时间等,可以根据需要另外增加字段。主要的逻辑就是,首先在前端计算文件的md5,然后跟后端交互,查询该文件是否已上传过,上传过多少分片,如果上传分片的条数跟前端计算的分片数一样,返回-1,否则返回上传分片的条数,前端根据这个数判断是否还需要上传、从哪个分片开始上传,这样就避免了上传过的分片重复上传。
注意:我这里采用的是通过文件内容来计算md5值,所以,如果只是修改了文件名称,计算出来的值是一样的,会认为是同一个文件。

package org.wjg.onlinexml.controller;

import io.micrometer.common.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.wjg.onlinexml.po.Result;
import org.wjg.onlinexml.po.ResultData;
import org.wjg.onlinexml.po.SysFilePo;
import org.wjg.onlinexml.service.SysFileService;

import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.Map;

@RestController
public class BigFileControll {
    // 获取资源文件夹的路径,路径为 项目所在路径/upload/
    private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/upload/";
    @Autowired
    private SysFileService sysFileService;

    private static String getSuffix(String filePath) {
        int dotIndex = filePath.lastIndexOf('.');
        if (dotIndex > 0 && dotIndex < filePath.length() - 1) {
            return filePath.substring(dotIndex + 1);
        }
        return "";
    }

    /**
     * 保存分片
     *
     * @param file
     * @param fileName
     * @param index
     * @return
     */
    @RequestMapping("/bigFileUpload")
    private Result bigFileUpload(@RequestParam("file") MultipartFile file, @RequestParam("fileName") String fileName,
                                 @RequestParam("md5") String md5, @RequestParam("index") int index, @RequestParam("status") int status) {
        if (file.isEmpty()) {
            return Result.builder().code(500).msg("上传失败!").build();
        }
        File uploadDir = new File(UPLOAD_DIR);
        if (!uploadDir.exists()) {
            uploadDir.mkdirs();
        }
        File uploadFile = new File(UPLOAD_DIR + fileName + "_" + index);
        try {
            //模拟上传中断-----------------------
            if (status == 1) {
                if (index == 2) {
                    return Result.builder().code(500).msg("上传失败").build();
                }
            }
            //-------------------结束------------------
            file.transferTo(uploadFile);
            SysFilePo sysFile = SysFilePo.builder()
                    .fileName(fileName)
                    .fileType(getSuffix(fileName))
                    .md5(md5)
                    .chunkIndex(index)
                    .build();
            sysFileService.insert(sysFile);
        } catch (Exception e) {
            e.printStackTrace();
            return Result.builder().code(500).msg("上传失败").build();
        }
        return Result.builder().code(200).msg("上传成功").build();
    }

    /**
     * 合并分片
     *
     * @param request
     * @return
     */
    @PostMapping("/merge")
    public Result mergeChunks(@RequestBody Map<String, String> request) {
        String filename = request.get("fileName");
        File mergedFile = new File(UPLOAD_DIR + filename);
        try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
            //循环获取分片,直到分片不存在为止
            for (int i = 0; ; i++) {
                File chunkFile = new File(UPLOAD_DIR + filename + "_" + i);
                if (!chunkFile.exists()) {
                    break;
                }
                //将分片复制到一个文件中,这种方法会追加
                Files.copy(chunkFile.toPath(), fos);
                //删除分片
                chunkFile.delete();
            }
        } catch (Exception e) {
            return Result.builder().code(500).msg("合并失败").build();
        }
        return Result.builder().code(200).msg("合并成功").build();
    }

    @GetMapping("/checkMD5")
    public Result mergeChunks(@RequestParam("md5") String md5, @RequestParam("totalChunks") int totalChunks) {
        if (StringUtils.isBlank(md5)) {
            return Result.builder().code(500).msg("上传的文件md5值为空!").build();
        }
        try {
            int startIndex = sysFileService.checkMD5(md5, totalChunks);
            return Result.builder().code(200).msg("MD5校验成功").data(ResultData.builder().startIndex(startIndex).build()).build();
        } catch (Exception e) {
            e.printStackTrace();
            return Result.builder().code(500).msg("校验文件md5值出错!").build();
        }
    }
}

Result 类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private int code;
    private String msg;
    private ResultData data;
}

ResultData 类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class ResultData {
    private int startIndex;
}

SysFilePo类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class SysFilePo {
    private String md5;
    private String fileName;
    private String fileType;
    private int chunkIndex;
}
    <select id="checkMD5" resultType="java.lang.Integer">
        select count(0) from sys_file
        <where>
            <if test="md5!=null and md5 !=''">
                md5 = #{md5,jdbcType=VARCHAR}
            </if>
        </where>
    </select>

    <insert id="insert">
        insert into sys_file
            (md5, file_name, file_type, chunk_index, insert_time, update_time)
        values (#{md5}, #{fileName}, #{fileType}, #{chunkIndex}, SYSDATE(), SYSDATE())
    </insert>

数据库脚本
可根据自身情况修改及增加主键。

CREATE TABLE `sys_file` (
  `md5` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文件md5值',
  `file_name` varchar(255) DEFAULT NULL COMMENT '文件名称',
  `file_type` varchar(255) DEFAULT NULL COMMENT '文件类型',
  `chunk_index` int(11) DEFAULT NULL COMMENT '分片下标',
  `insert_time` datetime DEFAULT NULL COMMENT '插入时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

总结:本篇相较于上篇,将上传的分片信息保存在数据库中了,这样页面刷新或者上传相同的文件时,可避免重复上传已经上传过的。其实本篇的写法对于一般情况已经满足,不过如果是超大文件,好几个g或者几十g的文件,有点不适用。问题一,计算md5会耗时较长,我们可以使用 webWorker 单独开线程去计算;问题二,就是一开始提到的,分片太多的时候,我们一个个上传太耗时,效率低,我们需要使用并发请求,这两个解决方案会放到下片文章。再次强调,前后端代码写的逻辑性不强,只为展示基础的用法,在实际使用时,可基于实际需求加以优化。后端代码部分为测试所用,已标注,请注意删除!!!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2095897.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

怎么在Windows操作系统部署阿里开源版通义千问(Qwen2)

怎么在Windows操作系统部署阿里开源版通义千问&#xff08;Qwen2&#xff09; | 原创作者/编辑&#xff1a;凯哥Java | 分类&#xff1a;人工智能学习系列教程 添加图片注释&#xff0c;不超过 140 字&#xff08;可选&#xff09; GitHub上qwen2截图 随着人工智能技术的不断…

【华为】轻松get!eNSP登录无线AC Web界面的新姿势

【华为】轻松get&#xff01;eNSP登录无线AC Web界面的新姿势 无线AC&#xff1a;web界面实验准备华为云配置01 拉取设备02添加UDP端口03再添加VMnet1(VMnet8 也行)网段连接AC的端口04最后设置端口映射 无线AC配置01拉取AC设备和连接华为云02配置AC的g0/0/1端口&#xff08;SVI…

AI时代,需要什么样的服务器操作系统?

文&#xff5c;刘俊宏 编&#xff5c;王一粟 AI时代&#xff0c;中国的服务器系统正在面临一场双重挑战。 今年6月底&#xff0c;最主流的开源服务器操作系统CentOS正式停服&#xff0c;找一个合适的操作系统进行迁移成为了必选项。同时&#xff0c;AI时代的到来&#xff0c…

笔记:《利用Python进行数据分析》之数据聚合

观前提示&#xff1a;这节内容不多&#xff0c;但难度较大&#xff0c;尤其是要能熟练运用时很不容易的 数据聚合 聚合指的是任何能够从数组产生标量值的数据转换过程。之前的例子已经用过一些&#xff0c;比如mean、count、min以及sum等。你可能想知道在GroupBy对象上调用me…

网络原理 - 初识

文章目录 局域网(LAN)广域网(WAN)网络设备IP地址格式 端口号格式 认识网络协议协议分层 OSI七层模型(只是理论,没有实际运用)TCP/IP五层&#xff08;或四层&#xff09;模型网络设备所在分层 封装和分用 计算机之间通过网络来传输数据&#xff0c;也称为网络通信。 根据网络互连…

AI问答:.NET核心组成概要、程序运行步骤和查询SDK版本的方法

.NET三大组成 ①Runtime (运行时)&#xff1a; CLR&#xff1a;公共语言运行时&#xff0c;执行程序、内存管理、垃圾回收&#xff08;GC&#xff09;、安全性检查、异常处理&#xff0c;是跨平台的关键要素。 JIT&#xff1a;实时编译器&#xff0c;将中间语言…

JDBC与数据库之间的操作(增删改查、获取主键、业务逻辑分离、属性文件配置)

参考视频哔哩哔哩 1、Service和Servicelmpl的概念 java中service和servicelmpl是常见的代码组织方式 Service是指业务逻辑的接口&#xff0c;定义了系统对外提供的功能。Servicelmpl是Service接口的具体实现&#xff0c;实现了具体的业务逻辑。 Service和Servicelmpl的好处…

Android自定义View实现不同朝向字体变色

实现效果&#xff1a; 1.一个文字两种颜色 2.实现不同朝向 3.结合ViewPager 思路&#xff1a;TextView可行&#xff1f;系统提供的只能够显示一种颜色&#xff0c;需要自定义View extends TextView&#xff1a;onMeasure()不需要实现 textColor颜色&#xff0c;textSize字体大小…

OpenAI Whisper API (InvalidRequestError)

题意: OpenAI Whisper API&#xff08;无效请求错误&#xff09; 问题背景&#xff1a; Im trying to use OpenAI Whisper API to transcribe my audio files. When I run it by opening my local audio files from disk, it worked perfectly. Now Im developing a FastAPI e…

学习WebGl基础知识(二)

学习目标&#xff1a; 掌握WebGl基础知识 学习内容&#xff1a; 创建一个Webgl程序 创建三维上下文对象创建顶点着色器和片元着色器创建和编译顶点着色器和片元着色器创建着色器程序对象绘制图元 创建一个Webgl程序 1.第一步获取画布&#xff0c;创建三维上下文对象 <ca…

一些硬件知识(十七)

电源芯片选型&#xff1a; 1.考虑拓扑结构 2.考虑功率&#xff0c;从而决定自行搭建电路还是选择芯片 3.对于低功耗产品&#xff0c;静态电流是非常重要的因素&#xff0c;一定重要考虑&#xff1a; 同步buck省去了续流二极管&#xff0c;效率比异步的高。 如果真的比耐压值…

ESP32小车:1.硬件模块与连接

一、硬件模块 总的元器件清单:亚克力板(三轮),两个普通减速电机,一个开关模块,一个 首先,需要一块亚克力单层底板,推荐随便在淘宝上买一块2WD亚克力单层板,比如: 最好亚克力板自带电机,买一套也不过15块。如果没有需另外购买两个普通TT直流减速电机和轮子。…

基于yolov8的102种昆虫检测系统python源码+onnx模型+评估指标曲线+精美GUI界面

【算法介绍】 基于YOLOv8的102种昆虫检测系统是一款高效、准确的昆虫识别工具&#xff0c;它利用YOLOv8这一先进的目标检测算法&#xff0c;实现了对102种不同昆虫的实时检测与识别。该系统在农业、生态研究、生物多样性保护等多个领域具有广泛的应用价值。 YOLOv8算法以其高…

HTML沙漏爱心

目录 写在前面 完整代码 下载代码 代码分析 系列文章 写在最后 写在前面 教你用HTML语言实现炫酷的沙漏爱心,该代码不仅可以用电脑运行,手机、平板也可以直接运行哦。 完整代码 <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"><…

【Linux】:文件IO

目录 1.C文件接口 1.1 当前路径是什么&#xff1f; 1.2 "w"和"a"​编辑 2.系统文件I/O 2.1 "比特宏"标识符的实现: 2.2 open 1.系统默认创建文件的权限只写 2.设置新建文件的权限 3. 覆盖写/清空写/追加写 3.访问文件的本质 3.1 文件…

茴香豆Web实践

茴香豆 是由书生浦语团队开发的一款开源、专门针对国内企业级使用场景设计并优化的知识问答工具。 茴香豆特点&#xff1a; 三阶段 Pipeline &#xff08;前处理、拒答、响应&#xff09;&#xff0c;提高相应准确率和安全性 打通微信和飞书群聊天&#xff0c;适合国内知识问…

提高工作效益方法(一)

目录 如何提高工作效率? 如何提高工作效率?&#xff08;每日工作安排&#xff09; 怎么在职场做好时间管理&#xff1f; 如何提高工作效率? 提高工作效率的关键在于采用一系列策略和方法&#xff0c;以确保工作能够高效、有序地进行。通过这些方法&#xff0c;可以有效地提…

【whisper】使用whisper实现语音转文字

whisper需要ffmpeg支持 官网下载ffmpeg https://www.gyan.dev/ffmpeg/builds/下载完毕后解压放到合适的位置 添加环境变量 在cmd中输入以下 ffmpeg -version出现下面结果代表成功 安装whisper pip install openai-whisper在vscode中运行 测试代码 import whisperif __n…

【c++】cout打印char * 或者char[]的细节详解

目录 char* 类型 1.打印指向的字符串 2.打印指针指向的地址 问题描述 解决方法 char型数组 1. 想要输出字符串 2. 想输出字符数组的地址 printf 和cout 的对比 1.打印首字符 2.打印字符串 3.打印字符串首地址 &#x1f497;感谢阅读&#xff01;&#x1f497; char*…

新火种AI|减脂增肌没捷径?对不起,那是AI 出现以前的事情了...

作者&#xff1a;小岩 编辑&#xff1a;彩云 对于很多人来说&#xff0c;“拥有完美的身材”是人生的重要目标之一&#xff0c;练出好身材的人也会以此为傲&#xff0c;会把自己的好身材po到社交媒体上。换个角度来说&#xff0c;为了让自己社交媒体上的形象足够完美&#xf…