為什麼使用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
請求,将剛剛上傳的分塊合為一個檔案,至此檔案的斷點分塊上傳就完成了。

未命名檔案
下面是對應的代碼~
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中遇到的問題和坑寫一寫,謝謝一直支援我的粉絲們~愛你們~