Springboot 实现大文件上传(支持断点续传)+渐进式视频播放+限速

参考链接: https://www.jianshu.com/p/aa44eb96c7b6
参考链接: https://www.jianshu.com/p/c449dec43099

以下仅作个人代码记录,若有不正确之处,希望大佬指出。

实现方式最近浏览的看到以下3种,自己总结了下分别做了demo放到了git中。下载即可运行

  • 第一种 使用 html5 + springboot 实现(支持断点续传) 基于sql
  • 第二种 使用 html5 + springboot 实现 (不支持断点续传,每次都是新文件,比较简单)
  • 第三种 使用 webuploader + Springboot 实现(支持断点续传) 基于redis

大文件上传(部分代码)

本文基于第二种方式实现,实现思路如下:

测试上传1.2G文件 大概用时53秒 后端还可以在优化 我就没做了

1.前端处理逻辑
  首先读取文件MD5检测后台数据库中 是否存在
    若已完成 则提示秒传 并做相应处理
    若未上传或未完成 则 进行切片 
  向后台发送请求check or upload 
    若 check 未上传 则执行upload 否则 跳过本次 执行下次
2.后端主要处理逻辑   
    1.将文件分片保存 并检查是否==文件总分片
      若等于则 合并文件 合并完成 删除分片文件  

现在贴出主要代码逻辑 ,完整请看git example1 里

前端:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>实现方案1</title>
</head>
<body>
<input type="file" id="file" />

<button id="upload">上传</button>

<span id="output" style="font-size:12px">等待</span>


<span id="usetime" style="font-size:12px;margin-left:20px;"></span>

<span id="uuid" style="font-size:12px;margin-left:20px;"></span>

<br/>
<div>上传百分比:<span id="percentage"></span> </div>
<br/>
<br/>
<br/>
<span id="param" style="font-size:12px;margin-left:20px;">param==</span>

<script src="http://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
<script src="js/spark-md5.js"></script>

<script>

</script>
<script>
    var chunk=-1; //分片数
    var succeed=0; //成功标识
    var dataBegin;//开始时间
    var dataEnd;//结束时间
    var action=false; //false检验分片是否上传过 (默认) true 表示上传文件
    const shardSize = 50 * 1024 * 1024;   //以50MB为一个分片
    var page={
        init: function () {
          $("#upload").click(function () {
             dataBegin=new Date();
             var file=$("#file")[0].files[0]; //文件对象
              $("#output").text("开始上传中。。。")
              CheckIsUpload(file);
          })
        }
    }
    $(function () {
        page.init();
    })
    /**
     * 检查文件是否上传
     */
    function CheckIsUpload(file) {
        chunk=-1;
        succeed=0; //成功标识
        dataBegin;//开始时间
        dataEnd;//结束时间
        action=false; //false检验分片是否上传过 (默认) true 表示上传文件
        //构建一个表单 FoemData Html5新增的
        var form=new FormData();
        var r=new FileReader();
        r.readAsArrayBuffer(file);
        $(r).load(function (e) {
           var blob= e.target.result;
           var fileMd5=SparkMD5.ArrayBuffer.hash(blob);
           form.append("md5",fileMd5);
           console.log(fileMd5)
            //Ajax提交
            $.ajax({
                url: "example1/isUpload",
                type: "POST",
                data: form,
                async: true,
                processData: false,//很重要,告诉jquery不要对form进行处理
                contentType: false,  //很重要,指定为false才能形成正确的Content-Type
                success: function (data) {
                   handleCheckIsUploadResult(file,fileMd5,data);
                },
                error: function(XMLHttpRequest, textStatus, errorThrown) {
                    alert("服务器出错!");
               }

            })
        })
    }
    /**
     * 处理检查是否上传的结果
     * @param file 当前文件
     * data: fileId  date  flag
     *     @param flag 0未上传 1 部分上传 2 妙传逻辑
     */
    function handleCheckIsUploadResult(file,fileMd5,data) {
        var uuid=data.fileId; //后端返回uuid
        let date=data.date;
        let flag=data.flag; //上传标记 0 未上传过 1 部分上传 2 妙传逻辑
        if (flag=="0"){
            //没有上传过文件
            upload(file,uuid,fileMd5,date)
        } else if (flag=="1"){
            //已经上传了部分
            upload(file,uuid,fileMd5,date);
        } else if (flag=="2"){
            alert("文件已经上传过 妙传了")
            $("#uuid").append(uuid);
        }
    }

    /**
     *
     * @param file文件对象
     * @param uuid 后端生成的uuid
     * @param filemd5 整个文件的md5
     * @param date 文件第一个分片上传日期
     */
    function upload(file,uuid,filemd5,date) {
        let name = file.name;        //文件名
        let size = file.size;        //总大小
        let shardCount = Math.ceil(size / shardSize);  //总片数
        //分片数>分片总数 结束本次方法调用
        if (chunk> shardCount) return;
        if(!action){
                chunk += 1;  //只有在检测分片时,i才去加1; 上传文件时无需加1
        }
        //计算每一片的起始和结束位置
        let start=chunk*shardSize;
        let end=Math.min(size,start+shardSize);
        //构造一个表单,FormData是HTML5新增的
        var form = new FormData();
        if(!action){
            form.append("action", "check");  //检测分片是否上传
            $("#param").append("action==check ");
        }else{
            form.append("action", "upload");  //直接上传分片
            form.append("data", file.slice(start,end));  //slice方法用于切出文件的一部分
            $("#param").append("action==upload ");
        }
        form.append("uuid", uuid);
        form.append("md5", filemd5);
        form.append("date", date);
        form.append("name", name);
        form.append("size", size);
        form.append("total", shardCount);  //总片数
        form.append("index", chunk+1);        //当前是第几片
        var index = chunk+1;
        $("#param").append("index=="+index+"<br/>");
        //按大小切割文件段  
        var data = file.slice(start, end);
        var r = new FileReader();
        r.readAsArrayBuffer(data);
        $(r).load(function (e) {
            var bolb = e.target.result;
            var partMd5=SparkMD5.ArrayBuffer.hash(bolb);
            form.append("partMd5", partMd5);
            //Ajax提交
            $.ajax({
                url: "example1/upload",
                type: "POST",
                data:form,
                async: true,
                processData: false,  //很重要,告诉jquery不要对form进行处理
                contentType: false,  //很重要,指定为false才能形成正确的Content-Type
                success: function (data) {
                    handleUploadResult(file,uuid,filemd5,date,shardCount,data);
                },error: function(XMLHttpRequest, textStatus, errorThrown) {
                    alert("服务器出错!");
                }
            })
        })
    }

    /**
     * 处理上传的结果
     * @param file
     * @param uuid
     * @param filemd5
     * @param date
     * @param shardCount
     * @param data
     */
    function handleUploadResult(file,uuid,filemd5,date,shardCount,data) {
        var fileuuid = data.fileId;
        var flag = data.flag;
        //服务器返回该分片是否上传过
        if (flag=="2"){
            ++succeed;
            $("#output").text(succeed + " / " + shardCount);
            //服务器返回分片是否上传成功
            if (succeed  == shardCount) {
                dataEnd = new Date();
                $("#uuid").text("uuid="+fileuuid);
                $("#usetime").text("用时:"+((dataEnd.getTime() - dataBegin.getTime())/1000)+"s");
                $("#param").append("<br/>" + "上传成功!");
            }
        }
        else{
            if (flag=="0"){
                //未上传,继续上传
                action = true;
            }
            else if (flag=="1"){
                action=false;
                ++succeed;
                $("#output").text(succeed + " / " + shardCount);
                $("#percentage").text(succeed/shardCount*100);
            }
            upload(file, uuid, filemd5, date);
        }
    }
</script>
</body>
</html>

后端:

package com.lovecyy.file.up.example1.service.impl;

import com.lovecyy.file.up.example1.constants.Constant;
import com.lovecyy.file.up.example1.dao.UploadFileRepository;
import com.lovecyy.file.up.example1.pojo.MultipartFileParam;
import com.lovecyy.file.up.example1.pojo.UploadFile;
import com.lovecyy.file.up.example1.service.UploadFileService;
import com.lovecyy.file.up.example1.utils.FileMd5Util;
import com.lovecyy.file.up.example1.utils.KeyUtil;
import com.lovecyy.file.up.example1.utils.NameUtil;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * @author ys
 * @topic
 * @date 2020/3/10 14:19
 */
@Service
public class UploadFileServiceImpl implements UploadFileService {
    private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");

    @Autowired
    UploadFileRepository uploadFileRepository;

    @Override
    public Map<String, Object> findByFileMd5(String md5) {
        UploadFile uploadFile = uploadFileRepository.findByFileMd5(md5);
        Map<String, Object> map = null;
        if (uploadFile == null) {
            //没有上传过文件
            map = new HashMap<>();
            map.put("flag", 0);
            map.put("fileId", KeyUtil.genUniqueKey());
            map.put("date", simpleDateFormat.format(new Date()));
        }else{
            //上传过文件 判断文件现在还是否存在
            File file = new File(uploadFile.getFilePath());
            if (!file.exists()){
                //若不存在
                map = new HashMap<>();
                map.put("flag", 0);
                map.put("fileId", uploadFile.getFileId());
                map.put("date", simpleDateFormat.format(new Date()));
            }
            //若文件存在 判断此时是部分上传了 还是已全部上传
            else{
                int fileStatus = uploadFile.getFileStatus().intValue();
                if (fileStatus==1){
                    //文件只上传了一部分
                    map = new HashMap<>();
                    map.put("flag", 1);
                    map.put("fileId", uploadFile.getFileId());
                    map.put("date", simpleDateFormat.format(new Date()));
                }else if (fileStatus==2){
                    //文件早已上传完整
                    map = new HashMap<>();
                    map.put("flag" , 2);
                }

            }
        }
        return map;
    }

    @Override
    public Map<String, Object> realUpload(MultipartFileParam form, MultipartFile multipartFile) throws IOException, Exception {
        String action = form.getAction();
        String fileId = form.getUuid();
        Integer index = Integer.valueOf(form.getIndex());
        String partMd5 = form.getPartMd5();
        String md5 = form.getMd5();
        Integer total = Integer.valueOf(form.getTotal());
        String fileName = form.getName();
        String size = form.getSize();
        String suffix = NameUtil.getExtensionName(fileName);

        String saveDirectory = Constant.PATH + File.separator + fileId;
        String filePath = saveDirectory + File.separator + fileId + "." + suffix;
        //验证路径是否存在,不存在则创建目录
        File path = new File(saveDirectory);
        if (!path.exists()) {
            path.mkdirs();
        }
        //文件分片位置
        File file = new File(saveDirectory, fileId + "_" + index);
        //根据action不同执行不同操作. check:校验分片是否上传过; upload:直接上传分片
        Map<String, Object> map = null;
        if ("check".equals(action)){
            String md5Str = FileMd5Util.getFileMD5(file);
            if (md5Str != null && md5Str.length() == 31) {
                System.out.println("check length =" + partMd5.length() + " md5Str length" + md5Str.length() + "   " + partMd5 + " " + md5Str);
                md5Str = "0" + md5Str;
            }

            if (md5Str != null && md5Str.equals(partMd5)) {
                //分片已上传过
                map = new HashMap<>();
                map.put("flag", "1");
                map.put("fileId", fileId);
                if(!index .equals(total))
                    return map;
            } else {
                //分片未上传
                map = new HashMap<>();
                map.put("flag", "0");
                map.put("fileId", fileId);
                return map;
            }
        }
        else if("upload".equals(action)){
            //分片上传过程中出错,有残余时需删除分块后,重新上传
            if (file.exists()) {
                file.delete();
            }

            multipartFile.transferTo(new File(saveDirectory, fileId + "_" + index));

            map = new HashMap<>();
            map.put("flag", "1");
            map.put("fileId", fileId);
            if(!index .equals(total) )
                return map;

        }

        if (path.isDirectory()){
            File[] fileArray = path.listFiles();
            if (fileArray!=null){
                if (fileArray.length == total){
                    //分块全部上传完毕,合并
                    File newFile = new File(saveDirectory, fileId + "." + suffix);
                    FileOutputStream outputStream = new FileOutputStream(newFile, true);//文件追加写入
                    for (int i = 0; i < fileArray.length; i++) {
                        File tmpFile = new File(saveDirectory, fileId + "_" + (i + 1));
                        FileUtils.copyFile(tmpFile,outputStream);
                        //应该放在循环结束删除 可以避免 因为服务器突然中断 导致文件合并失败 下次也无法再次合并
                        tmpFile.delete();
                    }
                    outputStream.close();
                   //修改FileRes记录为上传成功
                    UploadFile uploadFile = new UploadFile();
                    uploadFile.setFileId(fileId);
                    uploadFile.setFileStatus(2);
                    uploadFile.setFileName(fileName);
                    uploadFile.setFileMd5(md5);
                    uploadFile.setFileSuffix(suffix);
                    uploadFile.setFilePath(filePath);
                    uploadFile.setFileSize(size);

                    uploadFileRepository.save(uploadFile);
                    map=new HashMap<>();
                    map.put("fileId", fileId);
                    map.put("flag", "2");
                    return map;

                }else if (index==1){
                    //文件第一个分片上传时记录到数据库
                    UploadFile uploadFile = new UploadFile();
                    uploadFile.setFileMd5(md5);
                    String name = NameUtil.getFileNameNoEx(fileName);
                    if (name.length() > 32) {
                        name = name.substring(0, 32);
                    }
                    uploadFile.setFileName(name);
                    uploadFile.setFileSuffix(suffix);
                    uploadFile.setFileId(fileId);
                    uploadFile.setFilePath(filePath);
                    uploadFile.setFileSize(size);
                    uploadFile.setFileStatus(1);

                    uploadFileRepository.save(uploadFile);
                }
            }
        }

            return map;
    }
}

文件分片下载(直接上代码)

前台:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>操作视频</title>
</head>
<body>
<div style="text-align: center; margin-top: 50px; ">

    <h2>操作视频</h2>
    <video width="75%" height=600" controls="controls" controlslist="nodownload">
        <source id="my_video_1" src="http://localhost:8080/example1/download/movie.mp4" type="video/ogg"/>
        <source id="my_video_2" src="http://localhost:8080/example1/download/movie.mp4" type="video/mp4"/>
        Your browser does not support the video tag.
    </video>

    <h2 style="color: red;">该操作视频不存在或已经失效.</h2>

</div>
</body>
</html>

后台:

 @GetMapping("download/{fileName}")
   @ResponseBody
   public void videoStart(@PathVariable String fileName, HttpServletRequest request, HttpServletResponse response){
      String  filePath="E:\\idea\\base\\file\\1583847208056206066\\";
      String realPath = filePath + fileName;
      chunkDown(realPath,request,response);
    }

    /*******************************************************/
    /**
     * 若要实现文件限速下载 只需要后台向前台返回流的时候 睡眠一下就好
     */
    /*******************************************************/
    /**
     * 文件分片下载
     * @param filePath
     * @param request
     * @param response
     */
   public void  chunkDown(String filePath, HttpServletRequest request, HttpServletResponse response){

       String range = request.getHeader("Range");
       log.info("current request rang:" + range);
       File file = new File(filePath);
       //开始下载位置
       long startByte = 0;
       //结束下载位置
       long endByte = file.length() - 1;
       log.info("文件开始位置:{},文件结束位置:{},文件总长度:{}", startByte, endByte, file.length());
       if (range!=null && range.contains("bytes=")&&range.contains("-")){
           range = range.substring(range.lastIndexOf("=") + 1).trim();
           String[] ranges = range.split("-");
           try{
               //判断range的类型
               if (ranges.length == 1) {
                   //类型一:bytes=-2343
                   if (range.startsWith("-")) {
                       endByte = Long.parseLong(ranges[0]);
                   }
                   //类型二:bytes=2343-
                   else if (range.endsWith("-")) {
                       startByte = Long.parseLong(ranges[0]);
                   }
               } else if (ranges.length == 2) {
                   //类型三:bytes=22-2343
                   startByte = Long.parseLong(ranges[0]);
                   endByte = Long.parseLong(ranges[1]);
               }
           }catch (NumberFormatException e){
               startByte=0;
               endByte=file.length()-1;
               log.error("Range Occur Error, Message:{}",e.getLocalizedMessage());
           }
           //要下载的长度
           long contentLength = endByte - startByte + 1;
           //文件名
           String fileName = file.getName();
           //文件类型
           String contentType = request.getServletContext().getMimeType(fileName);
           解决下载文件时文件名乱码问题
           byte[] fileNameBytes = fileName.getBytes(StandardCharsets.UTF_8);
           fileName = new String(fileNameBytes, 0, fileNameBytes.length, StandardCharsets.ISO_8859_1);
          //各种响应头设置
           //支持断点续传,获取部分字节内容:
           response.setHeader("Accept-Ranges", "bytes");
           //http状态码要为206:表示获取部分内容
           response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
           response.setContentType(contentType);
           response.setHeader("Content-Type", contentType);
           //inline表示浏览器直接使用,attachment表示下载,fileName表示下载的文件名
           response.setHeader("Content-Disposition", "inline;filename=" + fileName);
           response.setHeader("Content-Length", String.valueOf(contentLength));
           // Content-Range,格式为:[要下载的开始位置]-[结束位置]/[文件总大小]
           response.setHeader("Content-Range", "bytes " + startByte + "-" + endByte + "/" + file.length());

           BufferedOutputStream outputStream = null;
           RandomAccessFile randomAccessFile = null;
           //已传送数据大小
           long transmitted = 0;
           try {
               randomAccessFile = new RandomAccessFile(file, "r");
               outputStream = new BufferedOutputStream(response.getOutputStream());
               byte[] buff = new byte[4096];
               int len = 0;
               randomAccessFile.seek(startByte);
               //坑爹地方四:判断是否到了最后不足4096(buff的length)个byte这个逻辑((transmitted + len) <= contentLength)要放前面!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
               //不然会会先读取randomAccessFile,造成后面读取位置出错,找了一天才发现问题所在
               //此处的原作者意思逻辑就是  (len = randomAccessFile.read(buff)) 每次读取4096个字节 eg 文件剩余2000 读4096 意味着 有2096
               //是空的  那么前端解析的时候就会出错  所以此处作者加了(transmitted + len) <= contentLength
               //条件判断
               while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buff)) != -1) {
                   outputStream.write(buff, 0, len);
                   transmitted += len;
               }
               //处理不足buff.length部分
               if (transmitted < contentLength) {
                   len = randomAccessFile.read(buff, 0, (int) (contentLength - transmitted));
                   outputStream.write(buff, 0, len);
                   transmitted += len;
               }

               outputStream.flush();
               response.flushBuffer();
               randomAccessFile.close();
               log.info("下载完毕:" + startByte + "-" + endByte + ":" + transmitted);
           } catch (ClientAbortException e) {
               log.warn("用户停止下载:" + startByte + "-" + endByte + ":" + transmitted);
               //捕获此异常表示拥护停止下载
           } catch (IOException e) {
               e.printStackTrace();
               log.error("用户下载IO异常,Message:{}", e.getLocalizedMessage());
           }finally {
               try {
                   if (randomAccessFile != null) {
                       randomAccessFile.close();
                   }
               } catch (IOException e) {
                   e.printStackTrace();
               }
           }///end try
       }
   }

实现下载限速

未做出具体业务实现 主要思路是 向前端输出流的时候 Sleep实现

Demo地址:https://github.com/xiaoashuo/base/tree/master/hello-max-file-up

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐