vue-simple-uploader结合Spring boot实现文件分块上传

文件上传功能是我们在做开发时经常会遇到的。Spring boot默认上传文件非常小,好像最大可以修改配置文件支持50M。如果太大的文件直接上传,占用内存太严重,很容易造成后台崩溃。这里我在使用前端使用vue-simple-uploader,后台使用Spring boot实现文件分块(分片上传)。同时了为了保障文件的完整性,加入了md5校验。

vue-simple-uploader

基于Vue的前端上传插件,支持分块和断点上传、自动重传,自带进度条,界面十分友好。在没做分块上传之前,我一直觉得这块很难做,用了这个插件发现,前端几乎不用写代码,后端对接好这个插件的参数就可以了。
img

vue-simple-uploader中文APi地址

前端搭建

  1. 引入依赖
  npm install vue-simple-uploader --save
  npm install spark-md5 --save
  1. 在main.js中全局引用
import uploader from 'vue-simple-uploader'
Vue.use(uploader)
  1. 页面代码
    测试时可以只替换里面的url
<template>
  <div class="hello">
    <uploader :key="uploader_key" :options="options"
              :autoStart="false"
              class="uploader-example"
              @file-success="onFileSuccess" @file-added="filesAdded">
      <uploader-unsupport></uploader-unsupport>
      <uploader-drop>
        <uploader-btn :single="true" >选择文件</uploader-btn>
      </uploader-drop>
      <uploader-list></uploader-list>
    </uploader>
  </div>
</template>

<script>
  import SparkMD5 from 'spark-md5'

export default {
  name: 'HelloWorld',
  data(){
    return{
      uploader_key: new Date().getTime(),//这个用来刷新组件--解决不刷新页面连续上传的缓存上传数据(注:每次上传时,强制这个值进行更改---根据自己的实际情况重新赋值)
      options: {
        target: 'http://localhost:18002/ossserver/api/v1/material/chunkUpload',//SpringBoot后台接收文件夹数据的接口
        testChunks: false,//是否测试分片
      }
    }
  },
  props: {
    msg: String
  },
  methods:{
    onFileSuccess: function (rootFile, file, response, chunk) {
      console.log(rootFile)
      console.log(file)
      console.log(response)
      console.log(chunk)
    },
    /**
     * 计算md5,实现断点续传及秒传
     * @param file
     */
    computeMD5(file) {
      //大文件的md5计算时间比较长,显示个进度条
      const loading = this.$loading({
        lock: true,
        text: '正在计算MD5',
        spinner: 'el-icon-loading',
        background: 'rgba(0, 0, 0, 0.7)'
      });
      let fileReader = new FileReader();
      let time = new Date().getTime();
      let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
      let currentChunk = 0;
      const chunkSize = 10 * 1024 * 1000;
      let chunks = Math.ceil(file.size / chunkSize);
      let spark = new SparkMD5.ArrayBuffer();
      file.pause();

      loadNext();

      fileReader.onload = (e => {
        spark.append(e.target.result);
        if (currentChunk < chunks) {
          currentChunk++;
          loadNext();
          // 实时展示MD5的计算进度
          this.$nextTick(() => {
           console.log('校验MD5 '+ ((currentChunk/chunks)*100).toFixed(0)+'%')
          })
        } else {
          let md5 = spark.end();
          loading.close();
          this.computeMD5Success(md5, file);
          console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
        }
      });
      fileReader.onerror = function () {
        this.error(`文件${file.name}读取出错,请检查该文件`);
        loading.close();
        file.cancel();
      };
      function loadNext() {
        let start = currentChunk * chunkSize;
        let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
        fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
      }
    },

    computeMD5Success(md5, file) {
      file.uniqueIdentifier = md5;//把md5值作为文件的识别码
      file.resume();//开始上传
    },
    /**
     * 添加文件后触发
     * @param file
     * @param event
     */
    filesAdded(file, event){
      this.computeMD5(file)
    }
  }
}
</script>

<style>
  .uploader-example {
    width: 90%;
    padding: 15px;
    margin: 40px auto 0;
    font-size: 12px;
    box-shadow: 0 0 10px rgba(0, 0, 0, .4);
  }

  .uploader-example .uploader-btn {
    margin-right: 4px;
  }

  .uploader-example .uploader-list {
    max-height: 440px;
    overflow: auto;
    overflow-x: hidden;
    overflow-y: auto;
  }
</style>

后端代码

  1. 首先是Param 实体类,专门对接了vue-simple-uploader的参数
package com.grandtech.oss.domain;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import org.springframework.web.multipart.MultipartFile;

@ApiModel("大文件分片入参实体")
public class MultipartFileParam {
  @ApiModelProperty("文件传输任务ID")
  private String taskId;

  @ApiModelProperty("当前为第几分片")
  private int chunkNumber;

  @ApiModelProperty("每个分块的大小")
  private long chunkSize;


  @ApiModelProperty("分片总数")
  private int totalChunks;
  @ApiModelProperty("文件唯一标识")
  private String identifier;


  @ApiModelProperty("分块文件传输对象")
  private MultipartFile file;

  public String getTaskId() {
    return taskId;
  }

  public void setTaskId(String taskId) {
    this.taskId = taskId;
  }

  public int getChunkNumber() {
    return chunkNumber;
  }

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

  public long getChunkSize() {
    return chunkSize;
  }

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

  public int getTotalChunks() {
    return totalChunks;
  }

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

  public MultipartFile getFile() {
    return file;
  }

  public void setFile(MultipartFile file) {
    this.file = file;
  }

  public String getIdentifier() {
    return identifier;
  }

  public void setIdentifier(String identifier) {
    this.identifier = identifier;
  }
}
  1. Controller 接口
    注意返回的状态码,充分利用前端插件的自动重传
 @ApiOperation("大文件分片上传")
    @PostMapping("/chunkUpload")
    public void fileChunkUpload(MultipartFileParam param,  HttpServletRequest request,HttpServletResponse response){
        
        //自己的业务获取存储路径,可以换成自己的
        OSSInformation ossInformation = ossInformationService.queryOne();
        String root = ossInformation.getRoot();
        //验证文件夹规则,不能包含特殊字符
        File file = new File(root);
        createDirectoryQuietly(file);

        String path=file.getAbsolutePath();
        response.setContentType("text/html;charset=UTF-8");
        // response.setStatus对接前端插件
     //        200, 201, 202: 当前块上传成功,不需要重传。
     //        404, 415. 500, 501: 当前块上传失败,会取消整个文件上传。
     //        其他状态码: 出错了,但是会自动重试上传。

        try {
            /**
             * 判断前端Form表单格式是否支持文件上传
             */
            boolean isMultipart = ServletFileUpload.isMultipartContent(request);
            if(!isMultipart){
                //这里是我向前端发送数据的代码,可理解为 return 数据; 具体的就不贴了
                System.out.println("不支持的表单格式");
                response.setStatus(404);
                response.getOutputStream().write("不支持的表单格式".getBytes());
            }else {
            param.setTaskId(param.getIdentifier());
            materialService.chunkUploadByMappedByteBuffer(param,path);//service层
            response.setStatus(200);
            response.getWriter().print("上传成功");
            }
            response.getWriter().flush();
            
        } 
        catch (NotSameFileExpection e){
          response.setStatus(501);
        }
        catch (Exception e) {
            e.printStackTrace();
            System.out.println("上传文件失败");
            response.setStatus(415);
        }
    }
  1. Service 实现层
    中间的代码最初从网上借鉴的,最后发现很多逻辑都是错误的,而且异常太多。最后自己修改了逻辑了,替换了很多关键代码。
 @Override
    public String chunkUploadByMappedByteBuffer(MultipartFileParam param, String filePath) throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException,NotSameFileExpection {

        if(param.getTaskId() == null || "".equals(param.getTaskId())){
            param.setTaskId(UUID.randomUUID().toString());
        }
        /**
         *
         * 1:创建临时文件,和源文件一个路径
         * 2:如果文件路径不存在重新创建
         */
        String fileName = param.getFile().getOriginalFilename();
        String tempFileName = param.getTaskId() + fileName.substring(fileName.lastIndexOf(".")) + "_tmp";
        File fileDir = new File(filePath);
        if(!fileDir.exists()){
            fileDir.mkdirs();
        }
        File tempFile = new File(filePath,tempFileName);
        //第一步
        RandomAccessFile raf = new RandomAccessFile(tempFile,"rw");
        //第二步
        FileChannel fileChannel = raf.getChannel();
        //第三步 计算偏移量
        long position = (param.getChunkNumber()-1) * param.getChunkSize();
        //第四步
        byte[] fileData = param.getFile().getBytes();
        //第五步
        long end=position+fileData.length-1;
        fileChannel.position(position);
        fileChannel.write(ByteBuffer.wrap(fileData));
        //使用 fileChannel.map的方式速度更快,但是容易产生IO操作,无建议使用
//        MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,position,fileData.length);
//        //第六步
//        mappedByteBuffer.put(fileData);
        //第七步
//        freedMappedByteBuffer(mappedByteBuffer);
//        Method method = FileChannelImpl.class.getDeclaredMethod("unmap", MappedByteBuffer.class);
//        method.setAccessible(true);
//        method.invoke(FileChannelImpl.class, mappedByteBuffer);
        fileChannel.force(true);
        fileChannel.close();
        raf.close();
        //第八步
        boolean isComplete = checkUploadStatus(param,fileName,filePath);
         if(isComplete){
            //重命名文件,然后校验MD5文件是否一致
           String md5= DigestUtils.md5Hex(new FileInputStream(tempFile.getPath()));
               renameFile(tempFile,fileName);
           if(StringUtils.isNotBlank(md5) && !md5.equals(param.getIdentifier())){
               //不是同一文件抛出异常
               throw new NotSameFileExpection();
           }
        }
        return param.getTaskId();
    }

    /**
     * 文件重命名
     * @param toBeRenamed   将要修改名字的文件
     * @param toFileNewName 新的名字
     * @return
     */
    public void renameFile(File toBeRenamed, String toFileNewName) {
        //检查要重命名的文件是否存在,是否是文件
        if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
            System.out.println("文件不存在");
            return;
        }
        String p = toBeRenamed.getParent();
        File newFile = new File(p + File.separatorChar + toFileNewName);
        //修改文件名
         toBeRenamed.renameTo(newFile);
    }

    /**
     * 检查文件上传进度
     * @return
     */
    public boolean checkUploadStatus(MultipartFileParam param,String fileName,String filePath) throws IOException {
        File confFile = new File(filePath,fileName+".conf");
        RandomAccessFile confAccessFile = new RandomAccessFile(confFile,"rw");
        //设置文件长度
        confAccessFile.setLength(param.getTotalChunks());
        //设置起始偏移量
        confAccessFile.seek(param.getChunkNumber()-1);
        //将指定的一个字节写入文件中 127,
        confAccessFile.write(Byte.MAX_VALUE);
        byte[] completeStatusList = FileUtils.readFileToByteArray(confFile);
        confAccessFile.close();//不关闭会造成无法占用
        //这一段逻辑有点复杂,看的时候思考了好久,创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127
        for(int i = 0; i<completeStatusList.length; i++){
           if(completeStatusList[i]!=Byte.MAX_VALUE){
               return false;
           }
        }
          //如果全部文件上传完成,删除conf文件
          confFile.delete();
            return true;
    }

    /**
     * 在MappedByteBuffer释放后再对它进行读操作的话就会引发jvm crash,在并发情况下很容易发生
     * 正在释放时另一个线程正开始读取,于是crash就发生了。所以为了系统稳定性释放前一般需要检 查是否还有线程在读或写
     * @param mappedByteBuffer
     */
    public static void freedMappedByteBuffer(final MappedByteBuffer mappedByteBuffer) {
        try {
            if (mappedByteBuffer == null) {
                return;
            }
            mappedByteBuffer.force();
            AccessController.doPrivileged(new PrivilegedAction<Object>() {
                @Override
                public Object run() {
                    try {
                        Method getCleanerMethod = mappedByteBuffer.getClass().getMethod("cleaner", new Class[0]);
                        //可以访问private的权限
                        getCleanerMethod.setAccessible(true);
                        //在具有指定参数的 方法对象上调用此 方法对象表示的底层方法
                        sun.misc.Cleaner cleaner = (sun.misc.Cleaner) getCleanerMethod.invoke(mappedByteBuffer,
                            new Object[0]);
                        cleaner.clean();
                    } catch (Exception e) {
                        e.printStackTrace();
                        System.out.println("清理缓存出错!!!"+e.getMessage());
                    }
                    System.out.println("缓存清理完毕!!!");
                    return null;
                }
            });
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

效果

img

  • 2
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GIS开发者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值