springboot断点续传的两种方法

共 9270字,需浏览 19分钟

 ·

2020-12-24 00:46

点击上方蓝色字体,选择“标星公众号”

优质文章,第一时间送达

66套java从入门到精通实战课程分享

使用背景介绍

由于涉及到大文件就会出现前端提交到后端很慢或者超时的现象。所以本文讲一下断点/分片续传的方案。以下提供三种方案 :前提前端进行根据文件阈值进行切割分片提交多个分片到后台,每次与后台交互进行一个分片交互。涉及前端进度条的问题,可以使用假进度条实现(如果使用真进度条,需要频繁请求后端方知上传真实进度,此方式抛弃)

一、利用数据库记录上传分片的进度

前端把大文件进行按照设定的文件阈值进行分片好,进行提交后台进行上传分片文件,每次上传完分片数据库记录分片信息,下次前端提交上传时,校验该分片是否符合下个分片,整个大文件的分片提交完,则合并分片文件到正式存储目录

二、利用多个临时文件记录上传分片进度

前端把大文件进行按照设定的文件阈值进行分片好,进行提交后台进行上传分片文件,每次上传完分片创建临时文件记录分片信息,下次前端提交上传时,利用是否存在该分片文件且校验分片文件大小,如果不一致则删除分片文件,接收前端上传文件进行上传,整个大文件的分片提交完,则合并分片文件到正式存储目录

三、利用单个临时文件记录上传进度

前端把大文件进行按照设定的文件阈值进行分片好,进行提交后台进行上传分片文件,每次上传分片判断临时文件是否存在,否则需创建临时文件(代表该操作的唯一标识文件),上传的分片文件内容输入到这个临时文件,下次前端提交分片上传时,利用是否存在该临时文件且校验临时文件的大小是否匹配上传的分片开始处大小(每次交互前 前端需请求询问后端该文件上传的最终大小是多少, 以便前端进行续传)继续输入分片内容到临时文件,接收前端上传文件进行上传,整个大文件的分片提交完,则把临时文件拷贝到正式存储的文件进行存储。

推荐使用第三种,相比数据库记录方式可以对第三方进行解耦。

这里采用了两种方法去实现

方法一:借用数据库和多文件实现断点续传

该方法对数据有要求,需要提供分片个数的数据字段存储

引入jar包


  
   org.projectlombok
   lombok
   true
  

  
  
   commons-io
   commons-io
   2.4
  

  
   commons-fileupload
   commons-fileupload
   1.3.1
  


  
  
   mysql
   mysql-connector-java
   runtime
  


  
  
   com.baomidou
   mybatis-plus-boot-starter
   3.3.2
  


配置文件

spring.resources.static-locations=classpath:/static
server.port=8000
  
#设置上传图片的路径
file.basepath=D:/BaiduNetdiskDownload/

# 设置单个文件大小
spring.servlet.multipart.max-file-size= 50MB
# 设置单次请求文件的总大小
spring.servlet.multipart.max-request-size= 50MB


##设置要连接的mysql数据库
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=utf8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

创建文件存储表,该表一些字段对文件存储需要用到

create table file
(
 id INTEGER primary key AUTO_INCREMENT comment 'id',
 path varchar(100) not null COMMENT '相对路径',
 name varchar(100) COMMENT '文件名',
 suffix varchar(10) COMMENT '文件后缀',
 size int COMMENT '文件大小|字节B',
 created_at BIGINT(20) COMMENT '文件创建时间',
 updated_at bigint(20) COMMENT '文件修改时间',
 shard_index int comment '已上传分片',
 shard_size int COMMENT '分片大小|B',
 shard_total int COMMENT '分片总数',
 file_key varchar(100) COMMENT '文件标识'
)

创建实体类

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;


@Data
@TableName(value = "file")
public class FileDTO {
 /**
 * id
 */
 @TableId(value = "id"type = IdType.AUTO)
 private Integer id;

 /**
 * 相对路径
 */
 private String path;

 /**
 * 文件名
 */
 private String name;

 /**
 * 后缀
 */
 private String suffix;

 /**
 * 大小|字节B
 */
 private Integer size;


 /**
 * 创建时间
 */
 private Long createdAt;

 /**
 * 修改时间
 */
 private Long updatedAt;

 /**
 * 已上传分片
 */
 private Integer shardIndex;

 /**
 * 分片大小|B
 */
 private Integer shardSize;

 /**
 * 分片总数
 */
 private Integer shardTotal;

 /**
 * 文件标识
 */
 private String fileKey;

}

创建mapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.upload.entity.FileDTO;
import org.springframework.stereotype.Repository;

@Repository
public interface FileMapper extends BaseMapper {
}

创建service

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.demo.upload.dao.FileMapper;
import com.example.demo.upload.entity.FileDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class FileService {

 @Autowired
 private FileMapper fileMapper;

 //保存文件
 public void save(FileDTO file1){
  //根据 数据库的 文件标识来查询 当前视频 是否存在
  LambdaQueryWrapper lambda = new QueryWrapper().lambda();
  lambda.eq(FileDTO::getFileKey,file1.getFileKey());
  List fileDTOS = fileMapper.selectList(lambda);
  //如果存在就话就修改
  if(fileDTOS.size()!=0){
   //根据key来修改
   LambdaQueryWrapper lambda1 = new QueryWrapper().lambda();
   lambda1.eq(FileDTO::getFileKey,file1.getFileKey());
   fileMapper.update(file1,lambda1);
  }else
  {
   //不存在就添加
   fileMapper.insert(file1);
  }
 }

 //检查文件
 public List check(String key){
  LambdaQueryWrapper lambda = new QueryWrapper().lambda();
  lambda.eq(FileDTO::getFileKey,key);
  List dtos = fileMapper.selectList(lambda);
  return dtos;
 }

}

创建utils

import lombok.Data;

/**
 * 统一返回值
 *
 * @author zhangshuai
 *
 */
@Data
public class Result {

 // 成功状态码
 public static final int SUCCESS_CODE = 200;

 // 请求失败状态码
 public static final int FAIL_CODE = 500;

 // 查无资源状态码
 public static final int NOTF_FOUNT_CODE = 404;

 // 无权访问状态码
 public static final int ACCESS_DINE_CODE = 403;

 /**
  * 状态码
  */
 private int code;

 /**
  * 提示信息
  */
 private String msg;

 /**
  * 数据信息
  */
 private Object data;

 /**
  * 请求成功
  *
  * @return
  */
 public static Result ok() {
  Result r = new Result();
  r.setCode(SUCCESS_CODE);
  r.setMsg("请求成功!");
  r.setData(null);
  return r;
 }

 /**
  * 请求失败
  *
  * @return
  */
 public static Result fail() {
  Result r = new Result();
  r.setCode(FAIL_CODE);
  r.setMsg("请求失败!");
  r.setData(null);
  return r;
 }

 /**
  * 请求成功,自定义信息
  *
  * @param msg
  * @return
  */
 public static Result ok(String msg) {
  Result r = new Result();
  r.setCode(SUCCESS_CODE);
  r.setMsg(msg);
  r.setData(null);
  return r;
 }

 /**
  * 请求失败,自定义信息
  *
  * @param msg
  * @return
  */
 public static Result fail(String msg) {
  Result r = new Result();
  r.setCode(FAIL_CODE);
  r.setMsg(msg);
  r.setData(null);
  return r;
 }

 /**
  * 请求成功,自定义信息,自定义数据
  *
  * @param msg
  * @return
  */
 public static Result ok(String msg, Object data) {
  Result r = new Result();
  r.setCode(SUCCESS_CODE);
  r.setMsg(msg);
  r.setData(data);
  return r;
 }

 /**
  * 请求失败,自定义信息,自定义数据
  *
  * @param msg
  * @return
  */
 public static Result fail(String msg, Object data) {
  Result r = new Result();
  r.setCode(FAIL_CODE);
  r.setMsg(msg);
  r.setData(data);
  return r;
 }
 public Result code(Integer code){
  this.setCode(code);
  return this;
 }


 public Result data(Object data){
  this.setData(data);
  return this;
 }

 public Result msg(String msg){
  this.setMsg(msg);
  return this;
 }

}

创建controller

import com.example.demo.upload.entity.FileDTO;
import com.example.demo.upload.service.FileService;
import com.example.demo.upload.utils.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import java.io.*;
import java.util.List;
import java.util.UUID;

@Controller
@RequestMapping("/file")
@Slf4j
public class FileController {

 @Autowired
 FileService fileService;

 public static final String BUSINESS_NAME = "普通分片上传";

 // 设置图片上传路径
 @Value("${file.basepath}")
 private String basePath;

 @RequestMapping("/show")
 public String show(){
  return "file";
 }

 /**
  * 上传
  * @param file
  * @param suffix
  * @param shardIndex
  * @param shardSize
  * @param shardTotal
  * @param size
  * @param key
  * @return
  * @throws IOException
  * @throws InterruptedException
  */
 @RequestMapping("/upload")
 @ResponseBody
 public String upload(MultipartFile file,
       String suffix,
       Integer shardIndex,
       Integer shardSize,
       Integer shardTotal,
       Integer size,
       String key
       ) throws IOException, InterruptedException {
  log.info("上传文件开始");
  //文件的名称
  String name = UUID.randomUUID().toString().replaceAll("-""");
  // 获取文件的扩展名
  String ext = FilenameUtils.getExtension(file.getOriginalFilename());

  //设置图片新的名字
  String fileName = new StringBuffer().append(key).append(".").append(suffix).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4
  //这个是分片的名字
  String localfileName = new StringBuffer(fileName)
    .append(".")
    .append(shardIndex)
    .toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1

  // 以绝对路径保存重名命后的图片
  File targeFile=new File(basePath,localfileName);
  //上传这个图片
  file.transferTo(targeFile);
  //数据库持久化这个数据
  FileDTO file1=new FileDTO();
  file1.setPath(basePath+localfileName);
  file1.setName(name);
  file1.setSuffix(ext);
  file1.setSize(size);
  file1.setCreatedAt(System.currentTimeMillis());
  file1.setUpdatedAt(System.currentTimeMillis());
  file1.setShardIndex(shardIndex);
  file1.setShardSize(shardSize);
  file1.setShardTotal(shardTotal);
  file1.setFileKey(key);
  //插入到数据库中
  //保存的时候 去处理一下 这个逻辑
  fileService.save(file1);
  //判断当前是不是最后一个分页 如果不是就继续等待其他分页 合并分页
  if(shardIndex .equals(shardTotal) ){
   file1.setPath(basePath+fileName);
   this.merge(file1);
  }
  return "上传成功";
 }

 @RequestMapping("/check")
 @ResponseBody
 public Result check(String key){
  List check = fileService.check(key);
  //如果这个key存在的话 那么就获取上一个分片去继续上传
  if(check.size()!=0){
   return Result.ok("查询成功",check.get(0));
  }
  return Result.fail("查询失败,可以添加");
 }


 /**
  * @author fengxinglie
  * 合并分页
  */
 private void merge(FileDTO fileDTO) throws FileNotFoundException, InterruptedException {
  //合并分片开始
  log.info("分片合并开始");
  String path = fileDTO.getPath(); //获取到的路径 没有.1 .2 这样的东西
  //截取视频所在的路径
  path = path.replace(basePath,"");
  Integer shardTotal= fileDTO.getShardTotal();
  File newFile = new File(basePath + path);
  FileOutputStream outputStream = new FileOutputStream(newFile,true); // 文件追加写入
  FileInputStream fileInputStream = null; //分片文件
  byte[] byt = new byte[10 * 1024 * 1024];
  int len;
  try {
   for (int i = 0; i < shardTotal; i++) {
    // 读取第i个分片
    fileInputStream = new FileInputStream(new File(basePath + path + "." + (i + 1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
    while ((len = fileInputStream.read(byt)) != -1) {
     outputStream.write(byt, 0, len);
    }
   }
  } catch (IOException e) {
   log.error("分片合并异常", e);
  } finally {
   try {
    if (fileInputStream != null) {
     fileInputStream.close();
    }
    outputStream.close();
    log.info("IO流关闭");
   } catch (Exception e) {
    log.error("IO流关闭", e);
   }
  }
  log.info("分片结束了");
  //告诉java虚拟机去回收垃圾 至于什么时候回收 这个取决于 虚拟机的决定
  System.gc();
  //等待100毫秒 等待垃圾回收去 回收完垃圾
  Thread.sleep(100);
  log.info("删除分片开始");
  for (int i = 0; i < shardTotal; i++) {
   String filePath = basePath + path + "." + (i + 1);
   File file = new File(filePath);
   boolean result = file.delete();
   log.info("删除{},{}", filePath, result ? "成功" : "失败");
  }
  log.info("删除分片结束");
 }

}

创建html页面


"en">

 "UTF-8">
 Title






 "1px solid red">
  
   文件1
   
    "file" type="file" id="inputfile"/>
   
  
  
   
   
    "check()">提交
   
  
 



启动项目就直接访问页面测试,没毛病的哦,我这个基本借鉴别人的,然后方法二是我自己写的,可以优化套入你需要的格式就行

方法二:单文件断点续传

借用方法一的一些类,我把代码写在控制层的

  /**
     * 临时文件是否存在,存在是否是完整的文件(是就是秒传),不是完整的就分片继续上传,不完整告诉前台从哪里开始继续传
     *
     * @param key  文件的唯一标识
     * @param size 总文件大小
     * @return
     */
    @PostMapping("checkKey")
    @ResponseBody
    public Result checkSingleFile(@RequestParam("key") String key, @RequestParam("size") Integer size) {
        //临时单文件
        File newFile = new File(basePath + key);
        if (newFile.exists()) {
            if (newFile.length() == (size)) {
                return Result.ok("秒传成功", 0);
            }
            return Result.ok("秒传成功", newFile.length());
        } else {
            return Result.fail("查询失败,可以添加");
        }
    }

    /**
     * 单个临时文件分片上传
     *
     * @param file
     * @param fileName
     * @param size
     * @param key
     * @return
     * @throws FileNotFoundException
     */
    @PostMapping("singleFile")
    @ResponseBody
    public String uploadOneFile(MultipartFile file,
                                String fileName,
                                Integer size,
                                String key) throws FileNotFoundException {
        //临时单文件
        File newFile = new File(basePath + key);
        // 文件追加写入
        FileOutputStream outputStream = new FileOutputStream(newFile, true);
        //分片文件
        FileInputStream fileInputStream = null;
        byte[] byt = new byte[10 * 1024 * 1024];
        int len;
        try {
            System.err.println(file.getSize());
            fileInputStream = new FileInputStream(multipartFileToFile(file));
            while ((len = fileInputStream.read(byt)) != -1) {
                outputStream.write(byt, 0, len);
            }
        } catch (Exception e) {
            log.error("分片合并异常", e);
        } finally {
            try {
                if (fileInputStream != null) {
                    fileInputStream.close();
                }
                outputStream.close();
                log.info("IO流关闭");
            } catch (Exception e) {
                log.error("IO流关闭", e);
            }
        }
        if (newFile.length() == size) {
            log.info("分片拼接结束结束了,修改成真是的文件名称");
            newFile.renameTo(new File(basePath + fileName));
        }
        //告诉java虚拟机去回收垃圾 至于什么时候回收  这个取决于 虚拟机的决定
        System.gc();
        return "success";

    }


    /**
     * MultipartFile 转 File
     *
     * @param file
     * @throws Exception
     */
    public static File multipartFileToFile(MultipartFile file) throws Exception {
        File toFile = null;
        if (file.equals("") || file.getSize() <= 0) {
            file = null;
        } else {
            InputStream ins = null;
            ins = file.getInputStream();
            toFile = new File(file.getOriginalFilename());
            inputStreamToFile(ins, toFile);
            ins.close();
        }
        return toFile;
    }


    /**
     * InputStream 转 File
     *
     * @param ins
     * @param file
     */
    public static void inputStreamToFile(InputStream ins, File file) {
        try {
            OutputStream os = new FileOutputStream(file);
            int bytesRead = 0;
            byte[] buffer = new byte[10 * 1024 * 1024];
            while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
            os.close();
            ins.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

这个页面的代码,我写的是

 //这个单临时文件    start==================
    function test2(stratSize) {
        //永安里from表单提交
        var fd = new FormData();
        //获取表单中的file
        var file = $('#inputfile').get(0).files[0];
        //文件分片  以20MB去分片
        var shardSize = 1 * 1024 * 1024;
        //定义分片索引
        var shardIndex = 1;
        if (stratSize != '' && stratSize != 0) {
            shardIndex = stratSize/shardSize;
        }
        //定义分片的起始位置
        var start = (shardIndex - 1) * shardSize;
        //定义分片结束的位置  file哪里来的?
        var end = Math.min(file.size, start + shardSize);
        //从文件中截取当前的分片数据
        var fileShard = file.slice(start, end);
        //分片的大小
        var size = file.size;
        console.log("分片开始位置:" + start + "====结束位置:"+end + "=====文件大小:" + size);
        //总片数
        var shardTotal = Math.ceil(size / shardSize);
        //文件名
        var fileName = file.name;
        var suffix = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length).toLowerCase();
        //把视频的信息存储为一个字符串
        var filedetails = file.name + file.size + file.type + file.lastModifiedDate;
        //使用当前文件的信息用md5加密生成一个key 这个加密是根据文件的信息来加密的  如果相同的文件 加的密还是一样的
        var key = hex_md5(filedetails);
        var key10 = parseInt(key, 16);
        //把加密的信息 转为一个64位的
        var key62 = Tool._10to62(key10);
        //前面的参数必须和controller层定义的一样
        fd.append('file', fileShard);
        fd.append('fileName', fileName);
        fd.append('size', size);
        fd.append("key", key62);
        $.ajax({
            url: "/file/singleFile",
            type"post",
            cache: false,
            data: fd,
            processData: false,
            contentType: false,
            success: function (data) {
                //这里应该是一个递归调用
                if (shardIndex < shardTotal) {
                    var startSize = (shardIndex + 1) * shardSize;
                    test2(startSize);
                } else {
                    alert(data)
                }

            },
            error: function () {
                //请求出错处理
            }
        })
        //发送ajax请求把参数传递到后台里面
    }

    //判断这个加密文件存在不存在
    function check1() {
        var file = $('#inputfile').get(0).files[0];
        //把视频的信息存储为一个字符串
        var filedetails = file.name + file.size + file.type + file.lastModifiedDate;
        //使用当前文件的信息用md5加密生成一个key 这个加密是根据文件的信息来加密的  如果相同的文件 加的密还是一样的
        var key = hex_md5(filedetails);
        var key10 = parseInt(key, 16);
        //把加密的信息 转为一个64位的
        var key62 = Tool._10to62(key10);
        //检查这个key存在不存在
        $.ajax({
            url: "/file/checkKey",
            type"post",
            data: {'key': key62,'size':file.size},
            success: function (data) {
                console.log(data);
                if (data.code == 500) {
                    //这个方法必须抽离出来
                    test2(0);
                } else {
                    if (data.data == 0) {
                        alert("极速上传成功");
                    } else {
                        test2(data.data);
                    }
                }
            }
        })
    }
    // 这个单临时文件    end==================

方法二测试,你会发现秒传的那个会有点问题的,就是秒传那个只有第一次上传成功了,第二上传成功了,才会返回秒传的,我这个也没修改,应该这个也是按照现实的业务逻辑需要或不需要的,我那个是涉及到文件重命名,才会有的,看实际需要使用吧



版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文链接:

https://blog.csdn.net/qq_41134142/article/details/111307363





粉丝福利:Java从入门到入土学习路线图

???

?长按上方微信二维码 2 秒


感谢点赞支持下哈 

浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报