Spring Boot 2.x(十六):玩轉vue文件上傳

爲何使用Vue-Simple-Uploader

最近用到了Vue + Spring Boot來完成文件上傳的操做,踩了一些坑,對比了一些Vue的組件,發現了一個很好用的組件——Vue-Simple-Uploader前端

再說說爲何選用這個組件,對比vue-ant-design和element-ui的上傳組件,它能作到更多的事情,好比:vue

  • 可暫停、繼續上傳
  • 上傳隊列管理,支持最大併發上傳
  • 分塊上傳
  • 支持進度、預估剩餘時間、出錯自動重試、重傳等操做
  • 支持「快傳」,經過文件判斷服務端是否已存在從而實現「快傳」

因爲需求中須要用到斷點續傳,因此選用了這個組件,下面我會從最基礎的上傳開始提及:java

單文件上傳、多文件上傳、文件夾上傳

Vue代碼:git

<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屬性便可選擇文件夾進行上傳。github

uploadOption1:redis

uploadOptions1: {
        target: "//localhost:18080/api/upload/single",//上傳的接口
        testChunks: false, //是否開啓服務器分片校驗
        fileParameterName: "file",//默認的文件參數名
        headers: {},
        query() {},
        categaryMap: { //用於限制上傳的類型
          image: ["gif", "jpg", "jpeg", "png", "bmp"]
        }
}
複製代碼

在後臺的接口的編寫,咱們爲了方便,定義了一個chunk類用於接收組件默認傳輸的一些後面方便分塊斷點續傳的參數:spring

Chunk類sql

@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來接收喲~數據庫

接口方法:apache

@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請求,將剛剛上傳的分塊合爲一個文件,至此文件的斷點分塊上傳就完成了。

下面是對應的代碼~

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喲~)

前端:github.com/viyog/viboo…

後臺:github.com/viyog/viboo…

公衆號

相關文章
相關標籤/搜索