一個 Demo 搞定前後端大文件分片上傳、斷點續傳、秒傳

1 前言

文件上傳在項目開發中再常見不過了,大多項目都會涉及到圖片、音頻、視頻、文件的上傳,通常簡單的一個 Form 表單就可以上傳小文件了,但是遇到大文件時比如 1GB 以上,或者用戶網絡比較慢時,簡單的文件上傳就不能適用了,用戶辛苦傳了好幾十分鐘,到最後發現上傳失敗,這樣的系統用戶體驗是非常差的。

或者用戶上傳到一半時,把應用退出了,下次進來再次上傳,如果讓他從頭開始傳也是不合理的。本文主要通過一個 Demo 從前端、後端用實戰代碼演示小文件上傳、大文件分片上傳、斷點續傳、秒傳的開發原理。

2 小文件上傳

小文件小傳非常的簡單,本項目後端我們使用 SpringBoot 3.1.2 + JDK17,前端我們使用原生的 JavaScript+spark-md5.min.js 實現。

後端代碼

POM.xml 使用 springboot3.1.2JAVA 版本使用 JDK17

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.2</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>uploadDemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>uploadDemo</name>
<description>uploadDemo</description>
<properties>
    <java.version>17</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

JAVA 接文件接口:

@RestController
public class UploadController {

    public static final String UPLOAD_PATH = "D:\\upload\\";

    @RequestMapping("/upload")
    public ResponseEntity<Map<String, String>> upload(@RequestParam MultipartFile file) throws IOException {
        File dstFile = new File(UPLOAD_PATH, String.format("%s.%s", UUID.randomUUID(), StringUtils.getFilename(file.getOriginalFilename())));
        file.transferTo(dstFile);
        return ResponseEntity.ok(Map.of("path", dstFile.getAbsolutePath()));
    }

}

前端代碼

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>upload</title>
</head>
<body>
upload

<form enctype="multipart/form-data">
    <input type="file" >
    <input type="button" value="上傳" onclick="uploadFile()">
</form>

上傳結果
<span id="uploadResult"></span>

<script>
    var  uploadResult=document.getElementById("uploadResult")
    function uploadFile() {
        var fileInput = document.getElementById('fileInput');
        var file = fileInput.files[0];
        if (!file) return; // 沒有選擇文件

        var xhr = new XMLHttpRequest();
        // 處理上傳進度
        xhr.upload.onprogress = function(event) {
            var percent = 100 * event.loaded / event.total;
            uploadResult.innerHTML='上傳進度:' + percent + '%';
        };
        // 當上傳完成時調用
        xhr.onload = function() {
            if (xhr.status === 200) {
                uploadResult.innerHTML='上傳成功'+ xhr.responseText;
            }
        }
        xhr.onerror = function() {
            uploadResult.innerHTML='上傳失敗';
        }
        // 發送請求
        xhr.open('POST''/upload'true);
        var formData = new FormData();
        formData.append('file', file);
        xhr.send(formData);
    }
</script>

</body>
</html>

注意事項

在上傳過程會報文件大小限制錯誤,主要有三個參數需要設置:

org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (46302921) exceeds the configured maximum (10485760)

這裏需在 springboot 的 application.properties 或者 application.yml 中添加max-file-sizemax-request-size配置項,默認大小分別是 1M 和 10M,肯定不能滿足我們上傳需求的。

spring.servlet.multipart.max-file-size=1024MB  
spring.servlet.multipart.max-request-size=1024MB

如果使用 nginx 報 413 狀態碼413 Request Entity Too Large,Nginx 默認最大上傳 1MB 文件,需要在 nginx.conf 配置文件中的 http{ }添加配置項:client_max_body_size 1024m

3 大文件分片上傳

前端

前端上傳流程

大文件分片上傳前端主要有三步:

前端上傳代碼計算文件 MD5 值用了 spark-md5 這個庫,使用也是比較簡單的。這裏爲什麼要計算 MD5 簡單說一下,因爲文件在傳輸寫入過程中可能會出現錯誤,導致最終合成的文件可能和原文件不一樣,所以要對比一下前端計算的 MD5 和後端計算的 MD5 是不是一樣,保證上傳數據的一致性。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>分片上傳</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
</head>
<body>
分片上傳

<form enctype="multipart/form-data">
    <input type="file" >
    <input type="button" value="計算文件MD5" onclick="calculateFileMD5()">
    <input type="button" value="上傳" onclick="uploadFile()">
    <input type="button" value="檢測文件完整性" onclick="checkFile()">
</form>

<p>
    文件MD5:
    <span id="fileMd5"></span>
</p>
<p>
    上傳結果:
    <span id="uploadResult"></span>
</p>
<p>
    檢測文件完整性:
    <span id="checkFileRes"></span>
</p>


<script>
    //每片的大小
    var chunkSize = 1 * 1024 * 1024;
    var uploadResult = document.getElementById("uploadResult")
    var fileMd5Span = document.getElementById("fileMd5")
    var checkFileRes = document.getElementById("checkFileRes")
    var  fileMd5;


    function  calculateFileMD5(){
        var fileInput = document.getElementById('fileInput');
        var file = fileInput.files[0];
        getFileMd5(file).then((md5) ={
            console.info(md5)
            fileMd5=md5;
            fileMd5Span.innerHTML=md5;
        })
    }

    function uploadFile() {
        var fileInput = document.getElementById('fileInput');
        var file = fileInput.files[0];
        if (!file) return;
        if (!fileMd5) return;


        //獲取到文件
        let fileArr = this.sliceFile(file);
        //保存文件名稱
        let fileName = file.name;

        fileArr.forEach((e, i) ={
            //創建formdata對象
            let data = new FormData();
            data.append("totalNumber", fileArr.length)
            data.append("chunkSize", chunkSize)
            data.append("chunkNumber", i)
            data.append("md5", fileMd5)
            data.append("file", new File([e],fileName));
            upload(data);
        })


    }

    /**
     * 計算文件md5值
     */
    function getFileMd5(file) {
        return new Promise((resolve, reject) ={
            let fileReader = new FileReader()
            fileReader.onload = function (event) {
                let fileMd5 = SparkMD5.ArrayBuffer.hash(event.target.result)
                resolve(fileMd5)
            }
            fileReader.readAsArrayBuffer(file)
        })
    }


   function upload(data) {
       var xhr = new XMLHttpRequest();
       // 當上傳完成時調用
       xhr.onload = function () {
           if (xhr.status === 200) {
               uploadResult.append( '上傳成功分片:' +data.get("chunkNumber")+'\t' ) ;
           }
       }
       xhr.onerror = function () {
           uploadResult.innerHTML = '上傳失敗';
       }
       // 發送請求
       xhr.open('POST''/uploadBig'true);
       xhr.send(data);
    }

    function checkFile() {
        var xhr = new XMLHttpRequest();
        // 當上傳完成時調用
        xhr.onload = function () {
            if (xhr.status === 200) {
                checkFileRes.innerHTML = '檢測文件完整性成功:' + xhr.responseText;
            }
        }
        xhr.onerror = function () {
            checkFileRes.innerHTML = '檢測文件完整性失敗';
        }
        // 發送請求
        xhr.open('POST''/checkFile'true);
        let data = new FormData();
        data.append("md5", fileMd5)
        xhr.send(data);
    }

    function sliceFile(file) {
        const chunks = [];
        let start = 0;
        let end;
        while (start < file.size) {
            end = Math.min(start + chunkSize, file.size);
            chunks.push(file.slice(start, end));
            start = end;
        }
        return chunks;
    }

</script>

</body>
</html>
前端注意事項

前端調用 uploadBig 接口有四個參數:

計算大文件的 MD5 可能會比較慢,這個可以從流程上進行優化,比如上傳使用異步去計算文件 MD5、不計算整個文件 MD5 而是計算每一片的 MD5 保證每一片數據的一致性。

後端

後端就兩個接口 / uploadBig 用於每一片文件的上傳和 / checkFile 檢測文件的 MD5。

/uploadBig 接口設計思路

接口總體流程:

這裏需要注意的:

MappedByteBuffer寫文件的用法:

FileChannel fileChannel = randomAccessFile.getChannel();  
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, chunkNumber * chunkSize, fileData.length);  
mappedByteBuffer.put(fileData);
/checkFile 接口設計思路

/checkFile 接口流程:

大文件上傳完整 JAVA 代碼:

@RestController
public class UploadController {

    public static final String UPLOAD_PATH = "D:\\upload\\";

    /**
     * @param chunkSize   每個分片大小
     * @param chunkNumber 當前分片
     * @param md5         文件總MD5
     * @param file        當前分片文件數據
     * @return
     * @throws IOException
     */
    @RequestMapping("/uploadBig")
    public ResponseEntity<Map<String, String>> uploadBig(@RequestParam Long chunkSize, @RequestParam Integer totalNumber, @RequestParam Long chunkNumber, @RequestParam String md5, @RequestParam MultipartFile file) throws IOException {
        //文件存放位置
        String dstFile = String.format("%s\\%s\\%s.%s", UPLOAD_PATH, md5, md5, StringUtils.getFilenameExtension(file.getOriginalFilename()));
        //上傳分片信息存放位置
        String confFile = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);
        //第一次創建分片記錄文件
        //創建目錄
        File dir = new File(dstFile).getParentFile();
        if (!dir.exists()) {
            dir.mkdir();
            //所有分片狀態設置爲0
            byte[] bytes = new byte[totalNumber];
            Files.write(Path.of(confFile), bytes);
        }
        //隨機分片寫入文件
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(dstFile, "rw");
             RandomAccessFile randomAccessConfFile = new RandomAccessFile(confFile, "rw");
             InputStream inputStream = file.getInputStream()) {
            //定位到該分片的偏移量
            randomAccessFile.seek(chunkNumber * chunkSize);
            //寫入該分片數據
            randomAccessFile.write(inputStream.readAllBytes());
            //定位到當前分片狀態位置
            randomAccessConfFile.seek(chunkNumber);
            //設置當前分片上傳狀態爲1
            randomAccessConfFile.write(1);
        }
        return ResponseEntity.ok(Map.of("path", dstFile));
    }


    /**
     * 獲取文件分片狀態,檢測文件MD5合法性
     *
     * @param md5
     * @return
     * @throws Exception
     */
    @RequestMapping("/checkFile")
    public ResponseEntity<Map<String, String>> uploadBig(@RequestParam String md5) throws Exception {
        String uploadPath = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);
        Path path = Path.of(uploadPath);
        //MD5目錄不存在文件從未上傳過
        if (!Files.exists(path.getParent())) {
            return ResponseEntity.ok(Map.of("msg""文件未上傳"));
        }
        //判斷文件是否上傳成功
        StringBuilder stringBuilder = new StringBuilder();
        byte[] bytes = Files.readAllBytes(path);
        for (byte b : bytes) {
            stringBuilder.append(String.valueOf(b));
        }
        //所有分片上傳完成計算文件MD5
        if (!stringBuilder.toString().contains("0")) {
            File file = new File(String.format("%s\\%s\\", UPLOAD_PATH, md5));
            File[] files = file.listFiles();
            String filePath = "";
            for (File f : files) {
                //計算文件MD5是否相等
                if (!f.getName().contains("conf")) {
                    filePath = f.getAbsolutePath();
                    try (InputStream inputStream = new FileInputStream(f)) {
                        String md5pwd = DigestUtils.md5DigestAsHex(inputStream);
                        if (!md5pwd.equalsIgnoreCase(md5)) {
                            return ResponseEntity.ok(Map.of("msg""文件上傳失敗"));
                        }
                    }
                }
            }
            return ResponseEntity.ok(Map.of("path", filePath));
        } else {
            //文件未上傳完成,反回每個分片狀態,前端將未上傳的分片繼續上傳
            return ResponseEntity.ok(Map.of("chucks", stringBuilder.toString()));
        }

    }
    
}

配合前端上傳演示分片上傳,依次按如下流程點擊按鈕:

斷點續傳

有了上面的設計做斷點續傳就比較簡單的,後端代碼不需要改變,只要修改前端上傳流程就好了:

用 / checkFile 接口,文件裏如果有未完成上傳的分片,接口返回 chunks 字段對就的位置值爲 0,前端將未上傳的分片繼續上傳,完成後再調用 / checkFile 就完成了斷點續傳

{
    "chucks""111111111100000000001111111111111111111111111"
}

秒傳

秒傳也是比較簡單的,只要修改前端代碼流程就好了,比如張三上傳了一個文件,然後李四又上傳了同樣內容的文件,同一文件的 MD5 值可以認爲是一樣的(雖然會存在不同文件的 MD5 一樣,不過概率很小,可以認爲 MD5 一樣文件就是一樣),10 萬不同文件 MD5 相同概率爲110000000000000000000000000000\frac{1}{10000000000000000000000000000}100000000000000000000000000001,福利彩票的中頭獎的概率一般爲11000000\frac{1}{1000000}10000001,具體計算方法可以參考走近消息摘要 --Md5 產生重複的概率,所以 MD5 衝突的概率可以忽略不計。

當李四調用 / checkFile 接口後,後端直接返回了李四上傳的文件路徑,李四就完成了秒傳。大部分雲盤秒傳的思路應該也是這樣,只不過計算文件 HASH 算法更爲複雜,返回給用戶文件路徑也更爲安全,要防止被別人算出文件路徑了。

秒傳前端代碼流程:

4 總結

本文從前端和後端兩個方面介紹了大文件的分片上傳、斷點繼續、秒傳設計思路和實現代碼,所有代碼都是親測可以直接使用。

來源:juejin.cn/post/7266265543412351030

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eFPpLwR_0L0NGS0OxAFOxw