天天看點

Spring Boot 2.x(十六):玩轉vue檔案上傳

為什麼使用Vue-Simple-Uploader

最近用到了Vue + Spring Boot來完成檔案上傳的操作,踩了一些坑,對比了一些Vue的元件,發現了一個很好用的元件——Vue-Simple-Uploader,先附上gayhub的

,再說說為什麼選用這個元件,對比vue-ant-design和element-ui的上傳元件,它能做到更多的事情,比如:

  • 可暫停、繼續上傳
  • 上傳隊列管理,支援最大并發上傳
  • 分塊上傳
  • 支援進度、預估剩餘時間、出錯自動重試、重傳等操作
  • 支援“快傳”,通過檔案判斷服務端是否已存在進而實作“快傳”

由于需求中需要用到斷點續傳,是以選用了這個元件,下面我會從最基礎的上傳開始說起:

單檔案上傳、多檔案上傳、檔案夾上傳

Vue代碼:

<uploader
        :options="uploadOptions1"
        :autoStart="true"
        class="uploader-app"
      >
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
          <uploader-btn style="margin-right:20px;" :attrs="attrs">選擇檔案</uploader-btn>
          <uploader-btn :attrs="attrs" directory>選擇檔案夾</uploader-btn>
        </uploader-drop>
        <uploader-list></uploader-list>
</uploader>           

複制

該元件預設支援多檔案上傳,這裡我們從官方demo中粘貼過來這段代碼,然後在

uploadOption1

中配置上傳的路徑即可,其中uploader-btn 中設定directory屬性即可選擇檔案夾進行上傳。

uploadOption1:

uploadOptions1: {
        target: "//localhost:18080/api/upload/single",//上傳的接口
        testChunks: false, //是否開啟伺服器分片校驗
        fileParameterName: "file",//預設的檔案參數名
        headers: {},
        query() {},
        categaryMap: { //用于限制上傳的類型
          image: ["gif", "jpg", "jpeg", "png", "bmp"]
        }
}           

複制

在背景的接口的編寫,我們為了友善,定義了一個chunk類用于接收元件預設傳輸的一些後面友善分塊斷點續傳的參數:

Chunk類

@Data
public class Chunk implements Serializable {

    private static final long serialVersionUID = 7073871700302406420L;

    private Long id;
    /**
     * 目前檔案塊,從1開始
     */
    private Integer chunkNumber;
    /**
     * 分塊大小
     */
    private Long chunkSize;
    /**
     * 目前分塊大小
     */
    private Long currentChunkSize;
    /**
     * 總大小
     */
    private Long totalSize;
    /**
     * 檔案辨別
     */
    private String identifier;
    /**
     * 檔案名
     */
    private String filename;
    /**
     * 相對路徑
     */
    private String relativePath;
    /**
     * 總塊數
     */
    private Integer totalChunks;
    /**
     * 檔案類型
     */
    private String type;

    /**
     * 要上傳的檔案
     */
    private MultipartFile file;
}           

複制

在編寫接口的時候,我們直接使用這個類作為參數去接收vue-simple-uploader傳來的參數即可,注意這裡要使用POST來接收喲~

接口方法:

@PostMapping("single")
    public void singleUpload(Chunk chunk) {
                   // 擷取傳來的檔案
        MultipartFile file = chunk.getFile();
        // 擷取檔案名
        String filename = chunk.getFilename();
        try {
            // 擷取檔案的内容
            byte[] bytes = file.getBytes();
            // SINGLE_UPLOADER是我定義的一個路徑常量,這裡的意思是,如果不存在該目錄,則去建立
            if (!Files.isWritable(Paths.get(SINGLE_FOLDER))) {
                Files.createDirectories(Paths.get(SINGLE_FOLDER));
            }
            // 擷取上傳檔案的路徑
            Path path = Paths.get(SINGLE_FOLDER,filename);
            // 将位元組寫入該檔案
            Files.write(path, bytes);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }           

複制

這裡需要注意一點,如果檔案過大的話,Spring Boot背景會報錯

org.apache.tomcat.util.http.fileupload.FileUploadBase$FileSizeLimitExceededException: The field file exceeds its maximum permitted size of 1048576 bytes.           

複制

這時需要在

application.yml

中配置servlet的最大接收檔案大小(預設大小是1MB和10MB)

spring:
  servlet:
    multipart:
      max-file-size: 10MB 
      max-request-size: 100MB           

複制

下面我們啟動項目,選擇需要上傳的檔案就可以看到效果了~ 是不是很友善~ 但是同樣的事情其餘的元件基本上也可以做到,之是以選擇這個,更多的是因為它可以支援斷點分塊上傳,實作上傳過程中斷網,再次聯網的話可以從斷點位置開始繼續秒傳~下面我們來看看斷點續傳是怎麼玩的。

斷點分塊續傳

先說一下分塊斷點續傳的大概原理,我們在元件可以配置分塊的大小,大于該值的檔案會被分割成若幹塊兒去上傳,同時将該分塊的

chunkNumber

儲存到資料庫(

Mysql

or

Redis

,這裡我選擇的是

Redis

元件上傳的時候會攜帶一個

identifier

的參數(這裡我采用的是預設的值,你也可以通過生成md5的方式來重新指派參數),将

identifier

作為

Redis

的key,設定hashKey為

”chunkNumber“

,value是由每次上傳的

chunkNumber

組成的一個

Set

集合。

在将

uploadOption

中的

testChunk

的值設定為

true

之後,該元件會先發一個get請求,擷取到已經上傳的chunkNumber集合,然後在

checkChunkUploadedByResponse

方法中判斷是否存在該片段來進行跳過,發送post請求上傳分塊的檔案。

每次上傳片段的時候,service層傳回目前的集合大小,并與參數中的totalChunks進行對比,如果發現相等,就傳回一個狀态值,來控制前端發出

merge

請求,将剛剛上傳的分塊合為一個檔案,至此檔案的斷點分塊上傳就完成了。

Spring Boot 2.x(十六):玩轉vue檔案上傳

未命名檔案

下面是對應的代碼~

Vue代碼:

<uploader
        :options="uploadOptions2"
        :autoStart="true"
        :files="files"
        @file-added="onFileAdded2"
        @file-success="onFileSuccess2"
        @file-progress="onFileProgress2"
        @file-error="onFileError2"
      >
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
          <uploader-btn :attrs="attrs">分塊上傳</uploader-btn>
        </uploader-drop>
        <uploader-list></uploader-list>
</uploader>           

複制

校驗是否上傳過的代碼

uploadOptions2: {
        target: "//localhost:18080/api/upload/chunk",
        chunkSize: 1 * 1024 * 1024,
        testChunks: true,
        checkChunkUploadedByResponse: function(chunk, message) {
          let objMessage = JSON.parse(message);
              // 擷取目前的上傳塊的集合
          let chunkNumbers = objMessage.chunkNumbers;
          // 判斷目前的塊是否被該集合包含,進而判定是否需要跳過
          return (chunkNumbers || []).indexOf(chunk.offset + 1) >= 0;
        },
        headers: {},
        query() {},
        categaryMap: {
          image: ["gif", "jpg", "jpeg", "png", "bmp"],
          zip: ["zip"],
          document: ["csv"]
        }
}           

複制

上傳後成功的處理,判斷狀态來進行merge操作

onFileSuccess2(rootFile, file, response, chunk) {
      let res = JSON.parse(response);
          // 背景報錯
      if (res.code == 1) {
        return;
      }
      // 需要合并
      if (res.code == 205) {
        // 發送merge請求,參數為identifier和filename,這個要注意需要和背景的Chunk類中的參數名對應,否則會接收不到~
        const formData = new FormData();
        formData.append("identifier", file.uniqueIdentifier);
        formData.append("filename", file.name);
        merge(formData).then(response => {});
      } 
    },           

複制

判定是否存在的代碼,注意這裡的是GET請求!!!

@GetMapping("chunk")
    public Map<String, Object> checkChunks(Chunk chunk) {
        return uploadService.checkChunkExits(chunk);
    }

    @Override
    public Map<String, Object> checkChunkExits(Chunk chunk) {
        Map<String, Object> res = new HashMap<>();
        String identifier = chunk.getIdentifier();
        if (redisDao.existsKey(identifier)) {
            Set<Integer> chunkNumbers = (Set<Integer>) redisDao.hmGet(identifier, "chunkNumberList");
            res.put("chunkNumbers",chunkNumbers);
        }
        return res;
    }           

複制

儲存分塊,并儲存資料到Redis的代碼。這裡的是POST請求!!!

@PostMapping("chunk")    
    public Map<String, Object> saveChunk(Chunk chunk) {
        // 這裡的操作和儲存單段落的基本是一緻的~
        MultipartFile file = chunk.getFile();
        Integer chunkNumber = chunk.getChunkNumber();
        String identifier = chunk.getIdentifier();
        byte[] bytes;
        try {
            bytes = file.getBytes();
            // 這裡的不同之處在于這裡進行了一個儲存分塊時将檔案名的按照-chunkNumber的進行儲存
            Path path = Paths.get(generatePath(CHUNK_FOLDER, chunk));
            Files.write(path, bytes);
        } catch (IOException e) {
            e.printStackTrace();
        }
                    // 這裡進行的是儲存到redis,并傳回集合的大小的操作
        Integer chunks = uploadService.saveChunk(chunkNumber, identifier);
        Map<String, Object> result = new HashMap<>();
        // 如果集合的大小和totalChunks相等,判定分塊已經上傳完畢,進行merge操作
        if (chunks.equals(chunk.getTotalChunks())) {
            result.put("message","上傳成功!");
            result.put("code", 205);
        }
        return result;
    }


    /**
      * 生成分塊的檔案路徑
      */
    private static String generatePath(String uploadFolder, Chunk chunk) {
        StringBuilder sb = new StringBuilder();
        // 拼接上傳的路徑
        sb.append(uploadFolder).append(File.separator).append(chunk.getIdentifier());
        //判斷uploadFolder/identifier 路徑是否存在,不存在則建立
        if (!Files.isWritable(Paths.get(sb.toString()))) {
            try {
                Files.createDirectories(Paths.get(sb.toString()));
            } catch (IOException e) {
                log.error(e.getMessage(), e);
            }
        }
        // 傳回以 - 隔離的分塊檔案,後面跟的chunkNumber友善後面進行排序進行merge
        return sb.append(File.separator)
                .append(chunk.getFilename())
                .append("-")
                .append(chunk.getChunkNumber()).toString();

    }

    /**
     * 儲存資訊到Redis
     */
    public Integer saveChunk(Integer chunkNumber, String identifier) {
        // 擷取目前的chunkList
        Set<Integer> oldChunkNumber = (Set<Integer>) redisDao.hmGet(identifier, "chunkNumberList");
        // 如果擷取為空,則建立Set集合,并将目前分塊的chunkNumber加入後存到Redis
        if (Objects.isNull(oldChunkNumber)) {
            Set<Integer> newChunkNumber = new HashSet<>();
            newChunkNumber.add(chunkNumber);
            redisDao.hmSet(identifier, "chunkNumberList", newChunkNumber);
            // 傳回集合的大小
            return newChunkNumber.size();
        } else {
                // 如果不為空,将目前分塊的chunkNumber加到目前的chunkList中,并存入Redis
            oldChunkNumber.add(chunkNumber);
            redisDao.hmSet(identifier, "chunkNumberList", oldChunkNumber);
            // 傳回集合的大小
            return oldChunkNumber.size();
        }

    }           

複制

合并的背景代碼:

@PostMapping("merge")
    public void mergeChunks(Chunk chunk) {
        String fileName = chunk.getFilename();
        uploadService.mergeFile(fileName,CHUNK_FOLDER + File.separator + chunk.getIdentifier());
    }

    @Override
    public void mergeFile(String fileName, String chunkFolder) {
        try {
            // 如果合并後的路徑不存在,則建立
            if (!Files.isWritable(Paths.get(mergeFolder))) {
                Files.createDirectories(Paths.get(mergeFolder));
            }
            // 合并的檔案名
            String target = mergeFolder + File.separator + fileName;
            // 建立檔案
            Files.createFile(Paths.get(target));
            // 周遊分塊的檔案夾,并進行過濾和排序後以追加的方式寫入到合并後的檔案
            Files.list(Paths.get(chunkFolder))
                     //過濾帶有"-"的檔案
                    .filter(path -> path.getFileName().toString().contains("-"))
                     //按照從小到大進行排序
                    .sorted((o1, o2) -> {
                        String p1 = o1.getFileName().toString();
                        String p2 = o2.getFileName().toString();
                        int i1 = p1.lastIndexOf("-");
                        int i2 = p2.lastIndexOf("-");
                        return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
                    })
                    .forEach(path -> {
                        try {
                            //以追加的形式寫入檔案
                            Files.write(Paths.get(target), Files.readAllBytes(path), StandardOpenOption.APPEND);
                            //合并後删除該塊
                            Files.delete(path);
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    });
        } catch (IOException e) {
            e.printStackTrace();
        }
    }           

複制

至此,我們的斷點續傳就完美結束了,完整的代碼我已經上傳到gayhub~,歡迎 star fork pr~ (後面還會把博文也上傳到gayhub喲~)

前端:https://github.com/viyog/viboot-front

背景:https://github.com/viyog/viboot

寫在後面

最近由于家庭+工作忙昏了頭,鴿了這麼久很是抱歉,從這周開始恢複更新,同時本人在準備往大資料轉型,後續會出一系列的Java轉型大資料的學習筆記,包括Java基礎系列的深入解讀和重寫,同時Spring Boot系列還會一直保持連載,不過可能不會每周都更,我會把目前使用Spring Boot中遇到的問題和坑寫一寫,謝謝一直支援我的粉絲們~愛你們~