1. 一個(gè)Demo搞定前后端大文件分片上傳、斷點(diǎn)續(xù)傳、秒傳

        共 26064字,需瀏覽 53分鐘

         ·

        2023-11-09 17:52

        1前言

        文件上傳在項(xiàng)目開發(fā)中再常見不過了,大多項(xiàng)目都會(huì)涉及到圖片、音頻、視頻、文件的上傳,通常簡單的一個(gè)Form表單就可以上傳小文件了,但是遇到大文件時(shí)比如1GB以上,或者用戶網(wǎng)絡(luò)比較慢時(shí),簡單的文件上傳就不能適用了,用戶辛苦傳了好幾十分鐘,到最后發(fā)現(xiàn)上傳失敗,這樣的系統(tǒng)用戶體驗(yàn)是非常差的。

        或者用戶上傳到一半時(shí),把應(yīng)用退出了,下次進(jìn)來再次上傳,如果讓他從頭開始傳也是不合理的。本文主要通過一個(gè)Demo從前端、后端用實(shí)戰(zhàn)代碼演示小文件上傳、大文件分片上傳、斷點(diǎn)續(xù)傳、秒傳的開發(fā)原理。

        2小文件上傳

        小文件小傳非常的簡單,本項(xiàng)目后端我們使用SrpingBoot 3.1.2 + JDK17,前端我們使用原生的JavaScript+spark-md5.min.js實(shí)現(xiàn)。

        后端代碼

        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" name="fileInput" id="fileInput">
            <input type="button" value="上傳" onclick="uploadFile()">
        </form>

        上傳結(jié)果
        <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();
                // 處理上傳進(jìn)度
                xhr.upload.onprogress = function(event{
                    var percent = 100 * event.loaded / event.total;
                    uploadResult.innerHTML='上傳進(jìn)度:' + percent + '%';
                };
                // 當(dāng)上傳完成時(shí)調(diào)用
                xhr.onload = function({
                    if (xhr.status === 200) {
                        uploadResult.innerHTML='上傳成功'+ xhr.responseText;
                    }
                }
                xhr.onerror = function({
                    uploadResult.innerHTML='上傳失敗';
                }
                // 發(fā)送請(qǐng)求
                xhr.open('POST''/upload'true);
                var formData = new FormData();
                formData.append('file', file);
                xhr.send(formData);
            }
        </script>

        </body>
        </html>

        注意事項(xiàng)

        在上傳過程會(huì)報(bào)文件大小限制錯(cuò)誤,主要有三個(gè)參數(shù)需要設(shè)置:

        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配置項(xiàng),默認(rèn)大小分別是1M和10M,肯定不能滿足我們上傳需求的。

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

        如果使用nginx報(bào) 413狀態(tài)碼413 Request Entity Too Large,Nginx默認(rèn)最大上傳1MB文件,需要在nginx.conf配置文件中的 http{ }添加配置項(xiàng):client_max_body_size 1024m

        3大文件分片上傳

        前端

        前端上傳流程

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

        前端上傳代碼計(jì)算文件MD5值用了spark-md5這個(gè)庫,使用也是比較簡單的。這里為什么要計(jì)算MD5簡單說一下,因?yàn)槲募趥鬏攲懭脒^程中可能會(huì)出現(xiàn)錯(cuò)誤,導(dǎo)致最終合成的文件可能和原文件不一樣,所以要對(duì)比一下前端計(jì)算的MD5和后端計(jì)算的MD5是不是一樣,保證上傳數(shù)據(jù)的一致性。

        <!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" name="fileInput" id="fileInput">
            <input type="button" value="計(jì)算文件MD5" onclick="calculateFileMD5()">
            <input type="button" value="上傳" onclick="uploadFile()">
            <input type="button" value="檢測(cè)文件完整性" onclick="checkFile()">
        </form>

        <p>
            文件MD5:
            <span id="fileMd5"></span>
        </p>
        <p>
            上傳結(jié)果:
            <span id="uploadResult"></span>
        </p>
        <p>
            檢測(cè)文件完整性:
            <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) => {
                    //創(chuàng)建formdata對(duì)象
                    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);
                })


            }

            /**
             * 計(jì)算文件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();
               // 當(dāng)上傳完成時(shí)調(diào)用
               xhr.onload = function ({
                   if (xhr.status === 200) {
                       uploadResult.append( '上傳成功分片:' +data.get("chunkNumber")+'\t' ) ;
                   }
               }
               xhr.onerror = function ({
                   uploadResult.innerHTML = '上傳失敗';
               }
               // 發(fā)送請(qǐng)求
               xhr.open('POST''/uploadBig'true);
               xhr.send(data);
            }

            function checkFile({
                var xhr = new XMLHttpRequest();
                // 當(dāng)上傳完成時(shí)調(diào)用
                xhr.onload = function ({
                    if (xhr.status === 200) {
                        checkFileRes.innerHTML = '檢測(cè)文件完整性成功:' + xhr.responseText;
                    }
                }
                xhr.onerror = function ({
                    checkFileRes.innerHTML = '檢測(cè)文件完整性失敗';
                }
                // 發(fā)送請(qǐng)求
                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>
        前端注意事項(xiàng)

        前端調(diào)用uploadBig接口有四個(gè)參數(shù):

        計(jì)算大文件的MD5可能會(huì)比較慢,這個(gè)可以從流程上進(jìn)行優(yōu)化,比如上傳使用異步去計(jì)算文件MD5、不計(jì)算整個(gè)文件MD5而是計(jì)算每一片的MD5保證每一片數(shù)據(jù)的一致性。

        后端

        后端就兩個(gè)接口/uploadBig用于每一片文件的上傳和/checkFile檢測(cè)文件的MD5。

        /uploadBig接口設(shè)計(jì)思路

        接口總體流程:

        這里需要注意的:

        • MD5.conf每一次檢測(cè)文件不存在里創(chuàng)建個(gè)空文件,使用byte[] bytes = new byte[totalNumber];將每一位狀態(tài)設(shè)置為0,從0位天始,第N位表示第N個(gè)分片的上傳狀態(tài),0-未上傳 1-已上傳,當(dāng)每將上傳成功后使用randomAccessConfFile.seek(chunkNumber)將對(duì)就設(shè)置為1。

        • randomAccessFile.seek(chunkNumber * chunkSize);可以將光標(biāo)移到文件指定位置開始寫數(shù)據(jù),每一個(gè)文件每將上傳分片編號(hào)chunkNumber都是不一樣的,所以各自寫自己文件塊,多線程寫同一個(gè)文件不會(huì)出現(xiàn)線程安全問題。

        • 大文件寫入時(shí)用RandomAccessFile可能比較慢,可以使用MappedByteBuffer內(nèi)存映射來加速大文件寫入,不過使用MappedByteBuffer如果要?jiǎng)h除文件可能會(huì)存在刪除不掉,因?yàn)閯h除了磁盤上的文件,內(nèi)存的文件還是存在的。

        MappedByteBuffer寫文件的用法:

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

        /checkFile接口流程:

        大文件上傳完整JAVA代碼:

        @RestController
        public class UploadController {

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

            /**
             * @param chunkSize   每個(gè)分片大小
             * @param chunkNumber 當(dāng)前分片
             * @param md5         文件總MD5
             * @param file        當(dāng)前分片文件數(shù)據(jù)
             * @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);
                //第一次創(chuàng)建分片記錄文件
                //創(chuàng)建目錄
                File dir = new File(dstFile).getParentFile();
                if (!dir.exists()) {
                    dir.mkdir();
                    //所有分片狀態(tài)設(shè)置為0
                    byte[] bytes = new byte[totalNumber];
                    Files.write(Path.of(confFile), bytes);
                }
                //隨機(jī)分片寫入文件
                try (RandomAccessFile randomAccessFile = new RandomAccessFile(dstFile, "rw");
                     RandomAccessFile randomAccessConfFile = new RandomAccessFile(confFile, "rw");
                     InputStream inputStream = file.getInputStream()) {
                    //定位到該分片的偏移量
                    randomAccessFile.seek(chunkNumber * chunkSize);
                    //寫入該分片數(shù)據(jù)
                    randomAccessFile.write(inputStream.readAllBytes());
                    //定位到當(dāng)前分片狀態(tài)位置
                    randomAccessConfFile.seek(chunkNumber);
                    //設(shè)置當(dāng)前分片上傳狀態(tài)為1
                    randomAccessConfFile.write(1);
                }
                return ResponseEntity.ok(Map.of("path", dstFile));
            }


            /**
             * 獲取文件分片狀態(tài),檢測(cè)文件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));
                }
                //所有分片上傳完成計(jì)算文件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) {
                        //計(jì)算文件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 {
                    //文件未上傳完成,反回每個(gè)分片狀態(tài),前端將未上傳的分片繼續(xù)上傳
                    return ResponseEntity.ok(Map.of("chucks", stringBuilder.toString()));
                }

            }
            
        }

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

        斷點(diǎn)續(xù)傳

        有了上面的設(shè)計(jì)做斷點(diǎn)續(xù)傳就比較簡單的,后端代碼不需要改變,只要修改前端上傳流程就好了:

        用/checkFile接口,文件里如果有未完成上傳的分片,接口返回chunks字段對(duì)就的位置值為0,前端將未上傳的分片繼續(xù)上傳,完成后再調(diào)用/checkFile就完成了斷點(diǎn)續(xù)傳

        {
            "chucks""111111111100000000001111111111111111111111111"
        }

        秒傳

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

        當(dāng)李四調(diào)用/checkFile接口后,后端直接返回了李四上傳的文件路徑,李四就完成了秒傳。大部分云盤秒傳的思路應(yīng)該也是這樣,只不過計(jì)算文件HASH算法更為復(fù)雜,返回給用戶文件路徑也更為安全,要防止被別人算出文件路徑了。

        秒傳前端代碼流程:

        4總結(jié)

        本文從前端和后端兩個(gè)方面介紹了大文件的分片上傳、斷點(diǎn)繼續(xù)、秒傳設(shè)計(jì)思路和實(shí)現(xiàn)代碼,所有代碼都是親測(cè)可以直接使用。

        來源:juejin.cn/post/7266265543412351030
             
             

        往期推薦:

        Netty+SpringBoot 打造一個(gè) TCP 長連接通訊方案

        7k Star,一款開源的 Kafka 管理平臺(tái),功能齊全、頁面美觀!

        SpringBoot+RabbitMQ+Redis 開發(fā)一個(gè)秒殺系統(tǒng),細(xì)節(jié)打滿(附源碼)

        一套干凈的企業(yè)數(shù)據(jù)管理系統(tǒng),拿來直接用

        基于 Spring Boot 的車牌識(shí)別系統(tǒng)(附項(xiàng)目地址)

        騰訊低代碼神器開源!拖拽開發(fā),爽的飛起~

        瀏覽 606
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 91人人妻| 丁香色婷婷 | 麻豆国产精品永久免费视频 | 一边亲一边摸下边 | 91n-最新地址发布页 |