1. 程式人生 > >大檔案批量上傳斷點續傳檔案秒傳

大檔案批量上傳斷點續傳檔案秒傳

接上篇文章 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>.file-name{
            width: 200px;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }
        .upload-item>.upload-process{
            width: 50px;
            text-align: left;
        }
        .upload-item>.upload-status{
            width: 100px;
            text-align: center;
        }

        table{
            width: 100%;
            border-collapse: collapse;
            position: fixed;
            bottom: 200px;
            border: 1px solid whitesmoke;
        }
    </style>
</head>
<body>
    <div class="file-uploads">
        <input type="file" multiple id="file" />
        <button id="startUpload">開始上傳</button>
        <ul id="uploadfiles">

        </ul>

        <table class="" style="" id="table"  >
            <thead>
                <tr>
                    <td>檔名</td>
                    <td>檔案大小</td>
                    <td>已上傳大小</td>
                    <td>相對路徑</td>
                    <td>md5</td>
                </tr>
            </thead>
            <tbody></tbody>
        </table>
    </div>
<!--    <script src="jquery-1.8.3.min.js"></script>-->
    <script src="jquery1.11.1.min.js"></script>
    <script src="spark-md5.min.js"></script>

    <script>
        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=>fileInfo

        $(function () {
            // 用於除錯 begin 載入系統中已經上傳過的檔案列表
            $.ajax({
                type:'get',
                url:fileInfoPos,
                dataType:'json',
                success:function (res) {
                    let htmlCodes = [];

                    for(let i=0;i<res.length;i++){
                        htmlCodes.push('<tr>');
                        htmlCodes.push('<td>'+res[i].name+'</td>');
                        htmlCodes.push('<td>'+res[i].size+'</td>');
                        htmlCodes.push('<td>'+res[i].uploaded+'</td>');
                        htmlCodes.push('<td>'+res[i].relativePath+'</td>');
                        htmlCodes.push('<td>'+res[i].md5+'</td>');
                        htmlCodes.push('</tr>')
                    }
                   $('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 && files.length > 0){
                            for (let i=0;i<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 < 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 > 0){
                    uploadFiles[fileId].file = this.files[0];
                }
            }

            /**
             * 開始上傳
             */
            function beginUpload() {
                // 先對每一個檔案進行檢查,除斷點檔案不需要檢查外
                // console.log(uploadFiles);
                for(let fileId in uploadFiles){
                    // 如果斷點檔案沒有 file 資訊,直接失敗
                    if(uploadFiles[fileId].status == 5 && !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 && 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<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("<style>.upload-item::before{ width:"+(process * 1000)+ "% }</style>");

                                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('<li class="upload-item" fileId="'+fileId+'">');
                htmlCodes.push('<span class="file-name">'+(fileInfo.name || fileInfo.originFileName)+'</span>');
                htmlCodes.push('<span class="file-size">'+(fileInfo.size)+'</span>');
                htmlCodes.push('<span class="upload-process">'+process+' %</span>');
                htmlCodes.push('<span class="upload-status" >'+statusInfoMap[status+'']+'</span>');
                htmlCodes.push('<span class="tips">'+tips+'</span>');
                if(breakPoint){
                    htmlCodes.push('<input type="file" name="file"  style="margin-left: 10px;"/>');
                }
                htmlCodes.push('</li>');
                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 < chunks) {
                        loadNext();
                    } else {
                        let hash = spark.end();
                        callback(hash);
                    }
                }

                fileReader.onerror = function () {
                    console.warn('md5 計算時出錯');
                };

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

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

                loadNext();
            }

            function id() {
                return Math.floor(Math.random() * 1000);
            }
        });
        
    </script>
</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