断点续传:大文件分片上传

        有时用户上传下载文件需要历时数小时,万一线路中断,不具备断点续传的方式就只能从头重传,断点续传方式允许用户从上传下载断线的地方继续传送,这样大大减少了用户的烦恼。

        分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件。分片上传不仅可以避免因网络环境不好导致的一直需要从文件起始位置还是上传的问题,还能使用多线程对不同分块数据进行并发发送,提高发送效率,降低发送时间。

分片上传主要适用于以下几种场景:

        网络环境不好:当出现上传失败的时候,可以对失败的Part进行独立的重试,而不需要重新上传其他的Part。
        断点续传:中途暂停之后,可以从上次上传完成的Part的位置继续上传。
        加速上传:要上传到OSS的本地文件很大的时候,可以并行上传多个Part以加快上传。
        流式上传:可以在需要上传的文件大小还不确定的情况下开始上传。这种场景在视频监控等行业应用中比较常见。
        文件较大:一般文件比较大时,默认情况下一般都会采用分片上传。
分片上传的整个流程大致如下:

        将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
        初始化一个分片上传任务,返回本次分片上传唯一标识;
        按照一定的策略(串行或并行)发送各个分片数据块;
        发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。

前台展示

前台代码:

        上传之前检测是否存在已上传的分片文件,已存在分片则继续上传,否则重新开始上传。上传完成后执行合并分片操作,合并完成后删除之前的分片文件。

<div id="mb" style="line-height: 35px;padding:20px">
	<div style="margin-top: 25px;">
		<div id="progressBar" style="display:none;">
		    <div id="bar"><div id="progress">0%</div></div>
		</div>

            <input id="file" name="mov" type="file" />
            <input id="btn" type="button" value="点我上传" />
            <input id="btn1" type="button" value="点我停止上传" style="display:none;"/>
	</div>
</div>
<script>
class FileSliceUpload{
    constructor(testingUrl, uploadUrl, margeUrl,progressUrl, fileSelect) {
        this.testingUrl = testingUrl; // 检测文件上传的url
        this.uploadUrl = uploadUrl;//文件上传接口
        this.margeUrl = margeUrl; // 合并文件接口
        this.progressUrl = progressUrl; //进度接口
        this.fileSelect = fileSelect;
        this.fileObj = null;
        this.totalize = null;
        this.blockSize = 1024 * 1024 * 100; //每次上传多少字节1mb(最佳)
        this.sta = 0; //起始位置
        this.end =  this.sta +  this.blockSize; //结束位置
        this.count = 0; //分片个数
        this.barId = "bar"; //进度条id
        this.progressId = "progress";//进度数值ID
        this.fileSliceName = ""; //分片文件名称
        this.fileName = "";
        this.uploadFileInterval = null;  //上传文件定时器

    }
    //续传  在上传前先去服务器检测之前是否有上传过这个文件,如果还有返回上传的的分片,那么进行续传
    // 将当前服务器上传的最后一个分片会从新上传, 避免因为网络的原因导致分片损坏
    ​
<div id="mb" style="line-height: 35px;padding:20px">
	<div style="margin-top: 25px;">
		<div id="progressBar" style="display:none;">
		    <div id="bar"><div id="progress">0%</div></div>
		</div>

            <input id="file" name="mov" type="file" />
            <input id="btn" type="button" value="点我上传" />
            <input id="btn1" type="button" value="点我停止上传" style="display:none;"/>
	</div>
</div>
<script>
class FileSliceUpload{
    constructor(testingUrl, uploadUrl, margeUrl,progressUrl, fileSelect) {
        this.testingUrl = testingUrl; // 检测文件上传的url
        this.uploadUrl = uploadUrl;//文件上传接口
        this.margeUrl = margeUrl; // 合并文件接口
        this.progressUrl = progressUrl; //进度接口
        this.fileSelect = fileSelect;
        this.fileObj = null;
        this.totalize = null;
        this.blockSize = 1024 * 1024 * 100; //每次上传多少字节1mb(最佳)
        this.sta = 0; //起始位置
        this.end =  this.sta +  this.blockSize; //结束位置
        this.count = 0; //分片个数
        this.barId = "bar"; //进度条id
        this.progressId = "progress";//进度数值ID
        this.fileSliceName = ""; //分片文件名称
        this.fileName = "";
        this.uploadFileInterval = null;  //上传文件定时器

    }
    //续传  在上传前先去服务器检测之前是否有上传过这个文件,如果还有返回上传的的分片,那么进行续传
    // 将当前服务器上传的最后一个分片会从新上传, 避免因为网络的原因导致分片损坏
    sequelFile () {
        if (this.fileName) {
            var xhr = new XMLHttpRequest();
            //同步
            xhr.open('GET', this.testingUrl + "/" + this.fileName+ "/" + this.blockSize+ "/" + this.totalize, false);
            xhr.send();
            if (xhr.readyState === 4 && xhr.status === 200) {
                let ret = JSON.parse(xhr.response)
                if (ret.message == 'success') {
                    console.log("继续上传");
                    let data= ret.result;
                    this.count = data.code;
                    this.fileSliceName = data.fileSliceName
                    //计算起始位置和结束位置
                    this.sta = this.blockSize * this.count
                    //计算结束位置
                    this.end = this.sta + this.blockSize
                } else {
                    console.log("重新上传");
                    this.sta = 0; //从头开始
                    this.end = this.sta + this.blockSize;
                    this.count = 0; //分片个数
                }
            }
        }
    }

    stopUploadFile () {
        clearInterval(this.uploadFileInterval)
    }

    // 文件上传(单文件)
    startUploadFile () {
        // 进度条
        let bar = document.getElementById(this.barId)
        let progressEl = document.getElementById(this.progressId)
        this.fileObj = document.querySelector(this.fileSelect).files[0];
        this.totalize = this.fileObj.size;
        this.fileName = this.fileObj.name;

        //查询是否存在之前上传过此文件,然后继续
        this.sequelFile()
        let ref = this; //拿到当前对象的引用,因为是在异步中使用this就是他本身而不是class
        this.uploadFileInterval = setInterval(function () {
            if (ref.sta > ref.totalize) {
                //上传完毕后结束定时器
                clearInterval(ref.uploadFileInterval)
                //发送合并请求
                ref.margeUploadFile ()
                console.log("stop" + ref.sta);
                return;
            };
            //分片名称
            ref.fileSliceName = ref.fileName + "-slice-" + ref.count++
            //分割文件 ,
            var blob1 =  ref.fileObj.slice(ref.sta, ref.end);
            var fd = new FormData();
            fd.append('part', blob1);
            fd.append('fileSliceName', ref.fileSliceName);
            fd.append('fileSize', ref.totalize);
            var xhr = new XMLHttpRequest();
            xhr.open('POST',  ref.uploadUrl, false);
            xhr.send(fd); //tongbu发送文件

            xhr.onreadystatechange = function () {
                console.log("123123");
                if (xhr.readyState === 4 && xhr.status === 200) {
                    let ret = JSON.parse(xhr.response)
                    if (ret.message == 'success') {
                        //计算进度
                        let percent =  Math.ceil((ret.data*ref.blockSize/ ref.totalize) * 100)
                        if (percent > 100) {
                            percent=100

                        }
                        console.log("456456");
                        bar.style.width = percent + '%';
                        bar.style.backgroundColor = 'red';
                        progressEl.innerHTML = percent + '%'
                    }
                }
            }
            //起始位置等于上次上传的结束位置
            ref.sta =  ref.end;
            //结束位置等于上次上传的结束位置+每次上传的字节
            ref.end = ref.sta + ref.blockSize;

        }, 5)

    }

    margeUploadFile () {
        console.log("检测上传的文件完整性..........");
        var xhr = new XMLHttpRequest();
        //文件分片的名称/分片大小/总大小
        xhr.open('GET', this.margeUrl+ "/" + this.fileSliceName + "/" + this.blockSize + "/" + this.totalize, true);
        xhr.send(); //发送请求
        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4 && xhr.status === 200) {
                let ret = JSON.parse(xhr.response)
                if (ret.message == 'success') {
                    console.log("文件上传完毕");
                } else {
                    console.log("上传完毕但是文件上传过程中出现了异常", ret);
                }
            }
        }
    }
}
</script>
<script>
let testingUrl="/fse/upload/testing"
let uploadUrl="/fse/upload/uploads"
let margeUrl="/fse/upload/merge-file-slice"
let progressUrl="/fse/upload/progress"
let fileSliceUpload = new FileSliceUpload(testingUrl,uploadUrl,margeUrl,progressUrl,"#file")
let btn=  document.querySelector("#btn")
let btn1=  document.querySelector("#btn1")
btn.addEventListener("click",function () {
    fileSliceUpload.startUploadFile()
})
btn1.addEventListener("click",function () {
    fileSliceUpload.stopUploadFile()
})

</script>

​
}
</script>
<script>
let testingUrl="/fse/upload/testing"
let uploadUrl="/fse/upload/uploads"
let margeUrl="/fse/upload/merge-file-slice"
let progressUrl="/fse/upload/progress"
let fileSliceUpload = new FileSliceUpload(testingUrl,uploadUrl,margeUrl,progressUrl,"#file")
let btn=  document.querySelector("#btn")
let btn1=  document.querySelector("#btn1")
btn.addEventListener("click",function () {
    fileSliceUpload.startUploadFile()
})
btn1.addEventListener("click",function () {
    fileSliceUpload.stopUploadFile()
})

</script>

后台controller:采用MD5加密方式,生成上传文件的路径,每次上传之前都检测是否存在上传的分片。

​
//获取分片
    @GetMapping("/testing/{fileName}/{fileSlicSize}/{fileSize}")
    @ResponseBody
    public Result testing(@PathVariable String fileName, @PathVariable long fileSlicSize, @PathVariable long fileSize  ) throws Exception {
        String dir = FileSliceUploadUtil.fileNameMd5Dir(fileName,fileSize);
        String absoluteFilePathAndCreate = FileTool.getAbsoluteFilePathAndCreate(FileSliceUploadUtil.uploadslicedir)+File.separator+dir;
        File file = new File(absoluteFilePathAndCreate);
        if (file.exists()) {
            //从小到大文件进行按照序号排序,和判断分片是否损坏
            List<String> collect = FileSliceUploadUtil.fileSliceIsbadAndSort(file, fileSlicSize);
            //获取最后一个分片
            String fileSliceName = collect.get(collect.size() - 1);
            fileSliceName = new File(fileSliceName).getName();
            int code = FileSliceUploadUtil.fileId(fileSliceName);
            //服务器的分片总大小必须小于或者等于文件的总大小
            if ((code*fileSlicSize)<=fileSize) {
                Result result = new Result();
                HashMap<String, Object> map = new HashMap<>();
                map.put("code", code);
                map.put("fileSliceName", fileSliceName);
                result.setResult(map);
                return result;
            }else {
                //分片异常 ,删除全部分片文件,从新上传
                FileTool.delAllFile(absoluteFilePathAndCreate);
                return Result.error("error");
            }
        }
        //不存在
        return Result.error("error");
    }


    @PostMapping(value = "/uploads")
    @ResponseBody
    public Result uploads(HttpServletRequest request)  {
        String fileSliceName = request.getParameter("fileSliceName");
        long fileSize = Long.parseLong(request.getParameter("fileSize")); //文件大小
        String dir = FileSliceUploadUtil.fileSliceMd5Dir(fileSliceName,fileSize);
        String absoluteFilePathAndCreate = FileTool.getAbsoluteFilePathAndCreate(FileSliceUploadUtil.uploadslicedir+dir);
        FileTool.fileUpload(absoluteFilePathAndCreate,fileSliceName,request);
        int i = FileSliceUploadUtil.fileId(fileSliceName); //返回上传成功的文件id,用于前端计算进度
        Result result=new Result();
        result.setResult(i);
        return result;
    }


    /**
     *
     * @param fileSlicNamee
     * @param fileSlicSize 单个分片大小
     * @param fileSize 文件总大小
     * @return
     * @throws Exception
     */
    // 合并分片
    @GetMapping(value = "/merge-file-slice/{fileSlicNamee}/{fileSlicSize}/{fileSize}")
    @ResponseBody
    public Result mergeFileSlice(@PathVariable String fileSlicNamee,@PathVariable long fileSlicSize,@PathVariable long fileSize ) throws Exception {
        int l =(int) Math.ceil((double) fileSize / fileSlicSize); //有多少个分片
        String dir = FileSliceUploadUtil.fileSliceMd5Dir(fileSlicNamee,fileSize); //分片所在的目录
        String absoluteFilePathAndCreate = FileTool.getAbsoluteFilePathAndCreate(FileSliceUploadUtil.uploadslicedir+dir);
        File file=new File(absoluteFilePathAndCreate);
        if (file.exists()){
            List<String> filesAll = FileTool.listFiles(file.getAbsolutePath());
            //阻塞循环判断是否还在上传  ,解决前端进行ajax异步上传的问题
            int beforeSize=filesAll.size();

            while (true){
                Thread.sleep(1000);
                //之前分片数量和现在分片数据只差,如果大于1那么就在上传,那么继续
                filesAll = FileTool.listFiles(file.getAbsolutePath());
                if (filesAll.size()-beforeSize>=1){
                    beforeSize=filesAll.size();
                    //继续检测
                    continue;
                }
                //如果是之前分片和现在的分片相等的,那么在阻塞2秒后检测是否发生变化,如果还没变化那么上传全部完成,可以进行合并了
                //当然这不是绝对的,只能解决网络短暂的波动,因为有可能发生断网很长时间,网络恢复后文件恢复上传, 这个问题是避免不了的,所以我们在下面的代码进行数量的效验
                // 因为我们不可能一直等着他网好,所以如果1~3秒内没有上传新的内容,那么我们默认判定上传完毕
                if (beforeSize==filesAll.size()){
                    Thread.sleep(2000);
                    filesAll = FileTool.listFiles(file.getAbsolutePath());
                    if (beforeSize==filesAll.size()){
                        break;
                    }
                }
            }
            //分片数量效验
            if (filesAll.size()!=l){
                //分片缺少 ,删除全部分片文件,从新上传
                FileTool.delAllFile(absoluteFilePathAndCreate);
                return Result.error("error");
            }
            //获取实际的文件名称,组装路径
            String realFileName = FileSliceUploadUtil.realFileName(fileSlicNamee);
            File uploaddir = new File(FileSliceUploadUtil.uploaddir);
            if(!uploaddir.exists()||!uploaddir.isDirectory()){
                uploaddir.mkdirs();
            }
            String realFileNamePath = FileTool.getAbsoluteFileAndCreate(FileSliceUploadUtil.uploaddir+ realFileName);
            //从小到大文件进行按照序号排序 ,和检查分片文件是否有问题
            List<String> collect = FileSliceUploadUtil.fileSliceIsbadAndSort(file, fileSlicSize);

            File outputFile = new File(realFileNamePath);
            //创建文件
            outputFile.createNewFile();
            //输出流
            FileChannel outChnnel = new FileOutputStream(outputFile).getChannel();
            //合并
            FileChannel inChannel;
            for (String filePath : collect) {
                File fileSclice = new File(filePath);
                inChannel = new FileInputStream(fileSclice).getChannel();
                inChannel.transferTo(0, inChannel.size(), outChnnel);
                inChannel.close();
                //删除分片
                fileSclice.delete();
            }
            outChnnel.close();
        }else {
            //没有这个分片相关的的目录
            return Result.error("error");
        }
        return Result.ok("ok");
    }

​

工具类FileSliceUploadUtil

public static final  String identification="-slice-";
    public static final  String uploadslicedir="F:\\home\\sliceupload\\uploads"+File.separator+"slice"+File.separator;//分片目录
    public static final  String uploaddir="F:\\home\\sliceupload\\uploads"+File.separator+"real"+File.separator;//实际文件目录

    public static void main(String[] args) {
        System.out.println(HashUtil.md5("123123213"));
        System.out.println(HashUtil.md5("123123213"));
    }
    //获取分片文件的目录
    public static String fileSliceMd5Dir(String fileSliceName,long fileSize){
        int i = fileSliceName.indexOf(identification) ;
        String substring = fileSliceName.substring(0, i);
        String dir = HashUtil.md5(substring+fileSize);
        return dir;
    }
    //通过文件名称获取文件目录
    public static String fileNameMd5Dir(String fileName,long fileSize){
        return HashUtil.md5(fileName+fileSize);
    }
    //获取分片的实际文件名
    public static String realFileName(String fileSliceName){
        int i = fileSliceName.indexOf(identification) ;
        String substring = fileSliceName.substring(0, i);
        return substring;

    }
    //获取文件序号
    public static int fileId(String fileSliceName){
        int i = fileSliceName.indexOf(identification)+identification.length() ;
        String fileId = fileSliceName.substring(i);
        return Integer.parseInt(fileId);
    }



    //判断是否损坏
    public static List<String> fileSliceIsbadAndSort(File file, long fileSlicSize) throws Exception {
        String absolutePath = file.getAbsolutePath();
        List<String> filesAll = FileTool.listFiles(absolutePath);
        if (filesAll.size()<1){
            //分片缺少,删除全部分片文件 ,从新上传
            FileTool.delAllFile(absolutePath);
            throw  new Exception("分片损坏");
        }
        //从小到大文件进行按照序号排序
        List<String> collect = filesAll.stream().sorted((a, b) -> fileId(a) - fileId(b)).collect(Collectors.toList());
        //判断文件是否损坏,将文件排序后,进行前后序号相差大于1那么就代表少分片了
        for (int i = 0; i < collect.size()-1; i++) {
            //检测分片的连续度
            if (fileId(collect.get(i)) - fileId(collect.get(i+1))!=-1) {
                //分片损坏 删除全部分片文件 ,从新上传
                FileTool.delAllFile(absolutePath);
                throw  new Exception("分片损坏");
            }
            //检测分片的完整度
            if (new File(collect.get(i)).length()!=fileSlicSize) {
                //分片损坏 删除全部分片文件 ,从新上传
                FileTool.delAllFile(absolutePath);
                throw  new Exception("分片损坏");
            }
        }
        return  collect;
    }

工具类FileTool

/**
     * 创建文件夹
     * @param path
     * @return
     */
    public static String getAbsoluteFilePathAndCreate(String path){
        File dir = new File(path);
        if(!dir.exists()||!dir.isDirectory()){
            dir.mkdirs();
        }
        return path;
    }

    /**
     * 创建文件
     * @param path
     * @return
     */
    public static String getAbsoluteFileAndCreate(String path){
        try {
            File file = new File(path);
            if(!file.exists()){
                file.createNewFile();
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return path;
    }

    /**
     * 获取文件列表
     * @param path
     * @return
     */
    public static List<String> listFiles(String path){
        File file = new File(path);
        List<String> filesAll = new ArrayList<>();
        File[] listFiles = file.listFiles();
        for(File fileChild: listFiles){
            filesAll.add(fileChild.getAbsolutePath());
        }
        return filesAll;
    }

    /**
     *
     * @param directory 文件上传的目录
     * @param request
     */
    @SneakyThrows
    public static  void  fileUpload(String directory, String fileName, HttpServletRequest request)  {
        //上传的位置   这句代码就是将文件上传到当前服务器的根目录下uploads文件夹里添加
        MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
        MultiValueMap<String, MultipartFile> multiFileMap = multipartRequest.getMultiFileMap();
        //拿到所有上传的文件对象
        for (Map.Entry<String, MultipartFile> stringMultipartFileEntry : multiFileMap.toSingleValueMap().entrySet()) {
            MultipartFile uploadFile = stringMultipartFileEntry.getValue();
            //判断文件上传表单是否是空
            boolean empty = uploadFile.isEmpty();
            //如果是空 的file表单那么 跳过
            if (!empty) {
                //完成文件上传
                uploadFile.transferTo(new File(directory, fileName));
            } else {
                throw new Exception("上传的文件是空的");
            }
        }

    }

    /**
     * 删除文件夹里面的所有文件
     * 
     * @param path 文件夹路径
     */
    public static void delAllFile(String path) {
        File file = new File(path);
        if (!file.exists()) {
            return;
        }
        if (!file.isDirectory()) {
            return;
        }
        String[] childFiles = file.list();
        File temp = null;
        for (int i = 0; i < childFiles.length; i++) {
            //File.separator与系统有关的默认名称分隔符
            //在UNIX系统上,此字段的值为'/';在Microsoft Windows系统上,它为 '\'。
            if (path.endsWith(File.separator)) {
                temp = new File(path + childFiles[i]);
            } else {
                temp = new File(path + File.separator + childFiles[i]);
            }
            if (temp.isFile()) {
                temp.delete();
            }
            if (temp.isDirectory()) {
                delAllFile(path + "/" + childFiles[i]);// 先删除文件夹里面的文件
                delFolder(path + "/" + childFiles[i]);// 再删除空文件夹
            }
        }
    }

工具类HashUtil

public static String md5(String s) {
        char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
                'a', 'b', 'c', 'd', 'e', 'f' };
        try {
            byte[] bytes = s.getBytes();
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(bytes);
            bytes = md.digest();
            int j = bytes.length;
            char[] chars = new char[j * 2];
            int k = 0;
            for (int i = 0; i < bytes.length; i++) {
                byte b = bytes[i];
                chars[k++] = hexChars[b >>> 4 & 0xf];
                chars[k++] = hexChars[b & 0xf];
            }
            return new String(chars);
        } catch (Exception e) {
            return null;
        }
    }

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值