16°

大文件批量上传断点续传文件秒传

接上篇文章 java 超大文件分片上传 在其基础上继续实现 断点续传和文件秒传功能

在上篇中,我们可以使用 file. slice 方法对文件进行分片,可以从后台读到当前文件已经上传的大小,就可以知道从哪里开始切片,断点续传的原理就是基于这个的。

前端计算文件的 md5 ,后台数据库查询一遍(前提是把 md5 存储了,计算文件 md5 也是需要消耗时间的)即可知道是否有相同文件,这是实现文件秒传的方法。

可能存在的问题:

  • 有两个人同时在上传同一个文件,但前一个人还没有上传完成,此时第二个文件认为是新文件不能秒传
  • 此时获取文件原数据时需要将文件信息保存起来,重点是要保存 md5 ,保证一个文件的 md5 保计算一次
  • 获取断点文件时,真实的文件上传位置应该是从文件系统中读出来的

根据需求说明,后台应该存在四个接口,获取文件信息(包含是否可以秒传),获取断点文件列表,分片上传接口,文件完整性验证

全部源码位置 : https://gitee.com/sanri/example/tree/master/test-mvc

/**
     * 加载断点文件列表
     * @return
     */
@GetMapping("/breakPointFiles")
public List<FileInfoPo> breakPointFiles(){
    List<FileInfoPo> fileInfoPos = fileMetaDataRepository.breakPointFiles();
    return fileInfoPos;
}

/** * 获取文件元数据,判断文件是否可以秒传 * @param originFileName * @param fileSize * @param md5 * @return * @throws URISyntaxException */ @GetMapping("/fileMetaData") public FileMetaData fileMetaData(String originFileName, Long fileSize, String md5) throws URISyntaxException, MalformedURLException { FileMetaData similarFile = bigFileStorage.checkSimilarFile(originFileName,fileSize, md5); if(similarFile != null){ similarFile.setSecUpload(true);

    // 如果文件名不一致,则创建链接文件
    if(!similarFile.getOriginFileName() .equals(originFileName)) {
        bigFileStorage.createSimilarLink(similarFile);
    }
    return similarFile;
}

//获取文件相关信息
String baseName = FilenameUtils.getBaseName(originFileName);
String extension = FilenameUtils.getExtension(originFileName);

String finalFileName = bigFileStorage.rename(baseName, fileSize);
if(StringUtils.isNotEmpty(extension)){
    finalFileName += ("."+extension);
}

URI relativePath = bigFileStorage.relativePath(finalFileName);

//如果没有相似文件,则要创建记录到数据库中,为后面断点续传做准备
FileInfoPo fileInfoPo = new FileInfoPo();
fileInfoPo.setName(originFileName);
fileInfoPo.setType(extension);
fileInfoPo.setUploaded(0);
fileInfoPo.setSize(fileSize);
fileInfoPo.setRelativePath(relativePath.toString());
fileInfoPo.setMd5(md5);
fileMetaDataRepository.insert(fileInfoPo);

URI absoluteURI = bigFileStorage.absolutePath(relativePath);
FileMetaData fileMetaData = new FileMetaData(originFileName, finalFileName, fileSize, relativePath.toString(), absoluteURI.toString());
fileMetaData.setMd5(md5);
fileMetaData.setFileType(extension);
return fileMetaData;

}

/** * 获取当前文件已经上传的大小,用于断点续传 * @return */ @GetMapping("/filePosition") public long filePosition(String relativePath) throws IOException, URISyntaxException { return bigFileStorage.filePosition(relativePath); }

/** * 上传分段 * @param multipartFile * @return */ @PostMapping("/uploadPart") public long uploadPart(@RequestParam("file") MultipartFile multipartFile, String relativePath) throws IOException, URISyntaxException { bigFileStorage.uploadPart(multipartFile,relativePath); return bigFileStorage.filePosition(relativePath); }

/** * 检查文件是否完整 * @param relativePath * @param fileSize * @param md5 * @return */ @GetMapping("/checkIntegrity") public void checkIntegrity(String relativePath,Long fileSize,String fileName) throws IOException, URISyntaxException { long filePosition = bigFileStorage.filePosition(relativePath); Assert.isTrue(filePosition == fileSize ,"大文件上传失败,文件大小不完整 "+filePosition+" != "+fileSize); String targetMd5 = bigFileStorage.md5(relativePath); FileInfoPo fileInfoPo = fileMetaDataRepository.selectByPrimaryKey(fileName); String md5 = fileInfoPo.getMd5(); Assert.isTrue(targetMd5.equals(md5),"大文件上传失败,文件损坏 "+targetMd5+" != "+md5); //如果文件上传成功,更新文件上传大小 fileMetaDataRepository.updateFilePosition(fileName,filePosition); }

重要的处理部分其实还是前端,下面看前端的代码,需要使用到一个计算 md5 值的库 spark-md5.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>大文件批量上传,支持断点续传,文件秒传</title>
    <style>
        .upload-item{
            padding: 15px 10px;
            list-style-type: none;
        display: flex;
        flex-direction: row;
        margin-bottom: 10px;
        border: 1px dotted lightgray;
        width: 1000px;

        position: relative;
    }
    .upload-item:before{
        content: ' ';
        background-color: lightblue;
        width: 0px;
        position: absolute;
        left: 0;
        top: 0;
        bottom: 0;
        z-index: -1;
    }
    .upload-item span{
        display: block;
        margin-left: 20px;
    }
    .upload-item&gt;.file-name{
        width: 200px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .upload-item&gt;.upload-process{
        width: 50px;
        text-align: left;
    }
    .upload-item&gt;.upload-status{
        width: 100px;
        text-align: center;
    }

    table{
        width: 100%;
        border-collapse: collapse;
        position: fixed;
        bottom: 200px;
        border: 1px solid whitesmoke;
    }
&lt;/style&gt;

</head> <body> <div class="file-uploads"> <input type="file" multiple id="file" /> <button id="startUpload">开始上传</button> <ul id="uploadfiles">

    &lt;/ul&gt;

    &lt;table class="" style="" id="table"  &gt;
        &lt;thead&gt;
            &lt;tr&gt;
                &lt;td&gt;文件名&lt;/td&gt;
                &lt;td&gt;文件大小&lt;/td&gt;
                &lt;td&gt;已上传大小&lt;/td&gt;
                &lt;td&gt;相对路径&lt;/td&gt;
                &lt;td&gt;md5&lt;/td&gt;
            &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;&lt;/tbody&gt;
    &lt;/table&gt;
&lt;/div&gt;

<!-- <script src="jquery-1.8.3.min.js"></script>--> <script src="jquery1.11.1.min.js"></script> <script src="spark-md5.min.js"></script>

&lt;script&gt;
    const root = '';
    
    const breakPointFiles = root + '/breakPointFiles';      // 获取断点文件列表
    const fileMetaData = root + '/fileMetaData';            // 新上传文件元数据,secUpload 属性用于判断是否可以秒传
    const uploadPart = root +'/uploadPart';                 // 分片上传,每片的上传接口
    const checkIntegrity = root + '/checkIntegrity';        // 检查文件完整性
    const fileInfoPos = root + '/fileInfoPos';              // 获取系统中所有已经上传的文件(调试)
    
    const shardSize = 1024 * 1024 * 2;                      // 分片上传,每片大小 2M 
    const chunkSize = 1024 * 1024 * 4;                      // md5 计算每段大小 4M
    const statusInfoMap = {'0':'待上传','1':'正在计算','2':'正在上传','3':'上传成功','4':'上传失败','5':'暂停上传','6':'文件检查'};

    let uploadFiles = {};       //用于存储当前需要上传的文件列表 fileName=&gt;fileInfo

    $(function () {
        // 用于调试 begin 加载系统中已经上传过的文件列表
        $.ajax({
            type:'get',
            url:fileInfoPos,
            dataType:'json',
            success:function (res) {
                let htmlCodes = [];

                for(let i=0;i&lt;res.length;i++){
                    htmlCodes.push('&lt;tr&gt;');
                    htmlCodes.push('&lt;td&gt;'+res[i].name+'&lt;/td&gt;');
                    htmlCodes.push('&lt;td&gt;'+res[i].size+'&lt;/td&gt;');
                    htmlCodes.push('&lt;td&gt;'+res[i].uploaded+'&lt;/td&gt;');
                    htmlCodes.push('&lt;td&gt;'+res[i].relativePath+'&lt;/td&gt;');
                    htmlCodes.push('&lt;td&gt;'+res[i].md5+'&lt;/td&gt;');
                    htmlCodes.push('&lt;/tr&gt;')
                }
               $('table').append(htmlCodes.join(''))
            }
        })
        // 用于调试 end

        // 事件绑定
        $('#file').change(changeFiles);                                             // 选择文件列表事件
        $('#startUpload').click(beginUpload);                                       // 开始上传
        $('#uploadfiles').on('change','input[type=file]',breakPointFileChange);     // 断点文件选择事件

        // 初始化时加载断点文件 
        (function () {
            $.ajax({
                type:'get',
                url:breakPointFiles,
                dataType:'json',
                success:function (files) {
                    if(files &amp;&amp; files.length &gt; 0){
                        for (let i=0;i&lt;files.length;i++){
                            let fileId = id();
                            let process = parseFloat((files[i].uploaded / files[i].size ) * 100).toFixed(2);
                            $('#uploadfiles').append(templateUploadItem(fileId,files[i],process,5,'断点续传',i+1));
                            uploadFiles[fileId] = {fileInfo:files[i],status:5};
                        }
                    }
                }
            })
        })(window);

        /**
         * 文件重新选择事件
         * @param e
         */
        function changeFiles(e) {
            // 检测文件列表是否符合要求,默认都符合
            if(this.files.length == 0){return ;}

            // 先把文件信息追加上去,不做检查也不上传
            for (let i = 0; i &lt; this.files.length; i++) {
                let file = this.files[i];
                let fileId = id();
                $('#uploadfiles').append(templateUploadItem(fileId,file,0,0,''));
                uploadFiles[fileId] = {file:file,status:0};
            }

        }


        /**
         * 断点文件选择文件事件
        */
        function breakPointFileChange(e) {
            let fileId = $(e.target).closest('li').attr('fileId');
            if(this.files.length &gt; 0){
                uploadFiles[fileId].file = this.files[0];
            }
        }

        /**
         * 开始上传
         */
        function beginUpload() {
            // 先对每一个文件进行检查,除断点文件不需要检查外
            // console.log(uploadFiles);
            for(let fileId in uploadFiles){
                // 如果断点文件没有 file 信息,直接失败
                if(uploadFiles[fileId].status == 5 &amp;&amp; !uploadFiles[fileId].file){
                    //断点文件一定有 fileInfo
                    let fileInfo = uploadFiles[fileId].fileInfo;
                    let $li = $('#uploadfiles').find('li[fileId='+fileId+']');
                    $li.children('.upload-status').text('上传失败');fileInfo.status = 4;
                    $li.children('.tips').text('无文件信息');
                    continue;
                }
                if(uploadFiles[fileId].status == 5){
                    //如果断点文件有 file 信息,则可以直接断点续传了
                    let $li = $('#uploadfiles').find('li[fileId='+fileId+']');
                    $li.children('.upload-status').text('正在上传');uploadFiles[fileId].status = 2;
                    startUpload(uploadFiles[fileId],$li);
                    continue;
                }
                //其它待上传的文件,先后台检查文件信息,再上传
                if(uploadFiles[fileId].status  == 0){
                    let $li = $('#uploadfiles').find('li[fileId='+fileId+']');
                    uploadFiles[fileId].status = 1; $li.children('.upload-status').text('正在计算')     //正在计算
                    checkFileItem(uploadFiles[fileId].file,function (res) {
                        if(res.message &amp;&amp; res.message == 'fail'){
                            $li.children('.upload-status').text(res.returnCode ||  '上传出错');uploadFiles[fileId].status = 4;
                        }else{
                            uploadFiles[fileId].fileInfo = res;
                            if(res.secUpload){
                                $li.children('.upload-status').text('文件秒传');uploadFiles[fileId].status = 3;
                                $li.children('.upload-process').text('100 %');
                            }else{
                                $li.children('.upload-status').text('正在上传');uploadFiles[fileId].status = 2;
                                startUpload(uploadFiles[fileId],$li);
                            }
                        }
                    });
                }
            }

            /**
             * 计算 md5 值,请求后台查看是否可秒传
             */
            function checkFileItem(file,callback) {
                md5Hex(file,function (md5) {
                    $.ajax({
                        type:'get',
                        async:false,
                        url:fileMetaData,
                        data:{originFileName:file.name,fileSize:file.size,md5:md5},
                        dataType:'json',
                        success:callback
                    });
                });

            }

            /**
             * 开始正式上传单个文件
             * */
            function startUpload(uploadFile,$li) {
                let file = uploadFile.file;
                let offset = uploadFile.fileInfo.uploaded || 0;
                let shardCount =Math.ceil((file.size - offset )/shardSize);
                for(var i=0;i&lt;shardCount;i++){
                    var start = i * shardSize + offset;
                    var end = Math.min(file.size,start + shardSize );//在file.size和start+shardSize中取最小值,避免切片越界
                    var filePart = file.slice(start,end);
                    var formData = new FormData();
                    formData.append("file",filePart,uploadFile.fileInfo.name || uploadFile.fileInfo.originFileName);
                    formData.append('relativePath',uploadFile.fileInfo.relativePath);

                    $.ajax({
                        async:false,
                        url: uploadPart,
                        cache: false,
                        type: "POST",
                        data: formData,
                        dateType: 'json',
                        processData: false,
                        contentType: false,
                        success:function (uploaded) {
                            //进度计算
                            let process = parseFloat((uploaded / file.size) * 100).toFixed(2);
                            console.log(file.name+'|'+process);
                            $li.find('.upload-process').text(process + '%');

                            // 视觉进度
                            // $('.upload-item').append("&lt;style&gt;.upload-item::before{ width:"+(process * 1000)+ "% }&lt;/style&gt;");

                            if(uploaded == file.size){
                                // 上传完成后,检查文件完整性
                                $li.children('.upload-status').text('文件检查');
                                $.ajax({
                                    type:'get',
                                    async:false,
                                    url:checkIntegrity,
                                    data:{fileName:uploadFile.fileInfo.name || uploadFile.fileInfo.originFileName,fileSize:uploaded,relativePath:uploadFile.fileInfo.relativePath},
                                    success:function (res) {
                                        if(res.message != 'fail'){
                                            $li.children('.upload-status').text('上传成功');
                                        }else{
                                            $li.children('.upload-status').text('上传失败');
                                            $li.children('.tips').text(res.returnCode);
                                        }
                                    }
                                })
                            }
                        }
                    });
                }
            }
        }

        /**
         * 创建模板 html 上传文件项
         * @param fileName
         * @param process
         * @param status
         * @param tips
         * @returns {string}
         */
        function templateUploadItem(fileId,fileInfo,process,status,tips,breakPoint) {
            let htmlCodes = [];
            htmlCodes.push('&lt;li class="upload-item" fileId="'+fileId+'"&gt;');
            htmlCodes.push('&lt;span class="file-name"&gt;'+(fileInfo.name || fileInfo.originFileName)+'&lt;/span&gt;');
            htmlCodes.push('&lt;span class="file-size"&gt;'+(fileInfo.size)+'&lt;/span&gt;');
            htmlCodes.push('&lt;span class="upload-process"&gt;'+process+' %&lt;/span&gt;');
            htmlCodes.push('&lt;span class="upload-status" &gt;'+statusInfoMap[status+'']+'&lt;/span&gt;');
            htmlCodes.push('&lt;span class="tips"&gt;'+tips+'&lt;/span&gt;');
            if(breakPoint){
                htmlCodes.push('&lt;input type="file" name="file"  style="margin-left: 10px;"/&gt;');
            }
            htmlCodes.push('&lt;/li&gt;');
            return htmlCodes.join('');
        }

        /**
         * 计算 md5 值(同步计算)
         * @param file
         */
        function md5Hex(file,callback) {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
                chunks = Math.ceil(file.size / chunkSize),
                currentChunk = 0,

                spark = new SparkMD5.ArrayBuffer(),
                fileReader = new FileReader();

            fileReader.onload = function (e) {
                spark.append(e.target.result);                   // Append array buffer
                currentChunk++;
                if (currentChunk &lt; chunks) {
                    loadNext();
                } else {
                    let hash = spark.end();
                    callback(hash);
                }
            }

            fileReader.onerror = function () {
                console.warn('md5 计算时出错');
            };

            function loadNext(){
                var start = currentChunk * chunkSize,
                    end = ((start + chunkSize) &gt;= file.size) ? file.size : start + chunkSize;

                fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
            }

            loadNext();
        }

        function id() {
            return Math.floor(Math.random() * 1000);
        }
    });
    
&lt;/script&gt;

</body> </html>

源码位置: https://gitee.com/sanri/example/tree/master/test-mvc

一点小推广

创作不易,希望可以支持下我的开源软件,及我的小工具,欢迎来 gitee 点星,fork ,提 bug 。

Excel 通用导入导出,支持 Excel 公式 博客地址:https://blog.csdn.net/sanri1993/article/details/100601578 gitee:https://gitee.com/sanri/sanri-excel-poi

使用模板代码 ,从数据库生成代码 ,及一些项目中经常可以用到的小工具 博客地址:https://blog.csdn.net/sanri1993/article/details/98664034 gitee:https://gitee.com/sanri/sanri-tools-maven

本文由【sanri1993】发布于开源中国,原文链接:https://my.oschina.net/sanri/blog/3136129

全部评论: 0

    我有话说: