Spring boot + vue-simple-uploader实现分块上传,以及实现秒传
为什么使用Vue-Simple-Uploader
说说为什么选用这个组件,对比vue-ant-design和element-ui的上传组件,它能做到更多的事情,比如:
- 可暂停、继续上传
- 上传队列管理,支持最大并发上传
- 分块上传
- 支持进度、预估剩余时间、出错自动重试、重传等操作
- 支持“快传”,通过文件判断服务端是否已存在从而实现“快传”
断点分块续传(与下方的操作示例无关)
先说一下分块断点续传的大概原理,我们在组件可以配置分块的大小,大于该值的文件会被分割成若干块儿去上传,同时将该分块的chunkNumber保存到数据库(Mysql or Redis)
组件上传的时候会携带一个identifier的参数(这里我采用的是默认的值,你也可以通过生成md5的方式来重新赋值参数),将identifier作为Redis的key,设置hashKey为”chunkNumber“,value是由每次上传的chunkNumber组成的一个Set集合。
在将uploadOption中的testChunk的值设置为true之后,该组件会先发一个get请求,获取到已经上传的chunkNumber集合,然后在checkChunkUploadedByResponse方法中判断是否存在该片段来进行跳过,发送post请求上传分块的文件。
每次上传片段的时候,service层返回当前的集合大小,并与参数中的totalChunks进行对比,如果发现相等,就返回一个状态值,来控制前端发出merge请求,将刚刚上传的分块合为一个文件,至此文件的断点分块上传就完成了。

1.首先安装vue-simple-uploader和spark MD5
npm install vue-simple-uploader --save npm i spark MD5
2.然后在main.js里面添加
import uploader from 'vue-simple-uploader' Vue.use(uploader)
3.新建一个FileUpload.vue组件
<template>
<uploader :options="options" :autoStart="false"
@file-success="onFileSuccess"
@file-added="filesAdded">
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<uploader-btn>选择文件</uploader-btn>
</uploader-drop>
<uploader-list></uploader-list>
</uploader>
</template>
<script>
import SparkMD5 from 'spark-md5'
export default {
name: "FileUpload",
data(){
return {
options: {
target: '/erp/file_upload/chunk',//SpringBoot后台接收文件夹数据的接口
testChunks: true,//是否分片-分片
chunkSize: 30 * 1024 * 1024,//分块大小
fileParameterName: 'file',
maxChunkRetries: 3, //最大自动失败重试上传次数
checkChunkUploadedByResponse: function (chunk, message) {
let objMessage = JSON.parse(message);
// 获取当前的上传块的集合
let chunkNumbers = objMessage.result.chunkNumbers;
// 判断当前的块是否被该集合包含,从而判定是否需要跳过
return (chunkNumbers || []).indexOf(chunk.offset + 1) >= 0;
}
}
}
},methods:{
onFileSuccess: function (rootFile, file, response, chunk) {
let res = JSON.parse(response);
if(res.code == "error"){
_this.$message({
message: res.message,
type: 'error'
});
}
//文件以存在,直接调用回调函数
if (res.result.code == 200) {
//执行回调函数
this.$emit('doneBlock',res);
}
// 需要合并
if (res.result.code == 205) {
const formData = new FormData();
formData.append("identifier", file.uniqueIdentifier);
formData.append("filename", file.name);
formData.append("totalSize", file.size);
this.$axios.post("/erp/file_upload/merge", formData).then((res) => {
//执行回调函数
this.$emit('doneBlock',res.data);
})
}
},filesAdded(file, event){
this.computeMD5(file)
},computeMD5(file) {
const loading = this.$loading({
lock: true,
text: '正在计算MD5',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
let fileReader = new FileReader();
let time = new Date().getTime();
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
let currentChunk = 0;
const chunkSize = 30 * 1024 * 1024;
let chunks = Math.ceil(file.size / chunkSize);
let spark = new SparkMD5.ArrayBuffer();
file.pause();
loadNext();
fileReader.onload = (e => {
spark.append(e.target.result);
if (currentChunk < chunks) {
currentChunk++;
loadNext();
// 实时展示MD5的计算进度
this.$nextTick(() => {
console.log('校验MD5 '+ ((currentChunk/chunks)*100).toFixed(0)+'%')
})
} else {
let md5 = spark.end();
loading.close();
this.computeMD5Success(md5, file);
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
}
});
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`);
loading.close();
file.cancel();
};
function loadNext() {
let start = currentChunk * chunkSize;
let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},computeMD5Success(md5, file) {
file.uniqueIdentifier = md5;//把md5值作为文件的识别码
file.resume();//开始上传
}
}
}
</script>
4.在需要调用的页面加入代码:
HTML代码如下
<el-button type="primary" icon="el-icon-upload" @click="uploadFileVisible = true">上传</el-button>
<el-dialog title="上传资料" :visible.sync="uploadFileVisible" width="600px">
<file-upload style="height: 300px" @doneBlock="uploadSuccess"></file-upload>
</el-dialog>
5.JS代码如下
//导入我们刚刚新建的组件
import FileUpload from './group/FileUpload.vue';
export default {
name: "Test",
components: {FileUpload},
data() {
return {
uploadFileVisible:false
}
},methods: {
uploadFile(){
this.uploadFileVisible = true;
},uploadSuccess(obj){
//这里是上传成功后回调的函数,可以根据自己的业务需求来进行操作
//我这边上传成功后往数据库插入一条数据
var proFile ={};
proFile.fileName = obj.result.fileName;
proFile.recordDate = "";
proFile.recorder = localStorage.getItem('ms_username');
proFile.fileUrl = obj.result.fileUrl;
proFile.fileSize = obj.result.fileSize;
//保存的业务接口
this.$axios.post("/erp/test/save", proFile).then((res) => {
if (res.data.code == "error") {
this.$message({
message: res.data.msg,
type: 'error'
});
} else {
this.$message({
message: res.data.msg,
type: 'success'
});
this.getData();
}
})
}
}
}
6.接下来是后台代码
新建两个实体类,ErpFiles,ErpChunk
package com.example.entity; import java.io.Serializable; import java.util.Date; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; import org.hibernate.annotations.GenericGenerator; @Entity @Table(name = "erp_files") @GenericGenerator(name = "jpa-uuid", strategy = "uuid") public class ErpFiles implements Serializable{ /** * */ private static final long serialVersionUID = 1L; @Id @GeneratedValue(generator = "jpa-uuid") private String id; private String fileName; private String fileType; private String fileUrl; private Date creationTime; private String userName; private Long fileSize; private String fileMd5; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getFileName() { return fileName; } public void setFileName(String fileName) { this.fileName = fileName; } public String getFileType() { return fileType; } public void setFileType(String fileType) { this.fileType = fileType; } public String getFileUrl() { return fileUrl; } public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; } public Date getCreationTime() { return creationTime; } public void setCreationTime(Date creationTime) { this.creationTime = creationTime; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public Long getFileSize() { return fileSize; } public void setFileSize(Long fileSize) { this.fileSize = fileSize; } public String getFileMd5() { return fileMd5; } public void setFileMd5(String fileMd5) { this.fileMd5 = fileMd5; } } ErpChunk.java package com.example.entity; import java.io.Serializable; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; import javax.persistence.Transient; import org.hibernate.annotations.GenericGenerator; import org.springframework.web.multipart.MultipartFile; @Entity @Table(name = "erp_chunk") @GenericGenerator(name = "jpa-uuid", strategy = "uuid") public class ErpChunk implements Serializable { private static final long serialVersionUID = 7073871700302406420L; @Id @GeneratedValue(generator = "jpa-uuid") private String 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; /** * 要上传的文件 */ @Transient private MultipartFile file; @Transient private String parentId; public String getId() { return id; } public void setId(String id) { this.id = id; } public Integer getChunkNumber() { return chunkNumber; } public void setChunkNumber(Integer chunkNumber) { this.chunkNumber = chunkNumber; } public Long getChunkSize() { return chunkSize; } public void setChunkSize(Long chunkSize) { this.chunkSize = chunkSize; } public Long getCurrentChunkSize() { return currentChunkSize; } public void setCurrentChunkSize(Long currentChunkSize) { this.currentChunkSize = currentChunkSize; } public Long getTotalSize() { return totalSize; } public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } public String getIdentifier() { return identifier; } public void setIdentifier(String identifier) { this.identifier = identifier; } public String getFilename() { return filename; } public void setFilename(String filename) { this.filename = filename; } public String getRelativePath() { return relativePath; } public void setRelativePath(String relativePath) { this.relativePath = relativePath; } public Integer getTotalChunks() { return totalChunks; } public void setTotalChunks(Integer totalChunks) { this.totalChunks = totalChunks; } public String getType() { return type; } public void setType(String type) { this.type = type; } public MultipartFile getFile() { return file; } public void setFile(MultipartFile file) { this.file = file; } public String getParentId() { return parentId; } }
7.然后新建一个FileUploadController.java的文件
package com.example.controller; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.multipart.MultipartFile; import com.example.entity.ErpChunk; import com.example.entity.ErpFiles; import com.example.service.ErpChunkService; import com.example.service.ErpFilesService; import com.example.utils.GooidServerSay; @Controller @RequestMapping("/file_upload") public class FileUploadController { @Value("${file.uploadFolder1}") private String uploadFolder; @Value("${file.staticAccessPath1}") private String staticAccessPath; @Autowired private ErpChunkService chunkService; @Autowired private ErpFilesService filesService; /** * 验证当前文件块是否上传 * * @param chunk * @return */ @RequestMapping(value = "/chunk", method = RequestMethod.GET) @ResponseBody public GooidServerSay checkChunks(ErpChunk chunk) { GooidServerSay ret = new GooidServerSay(); try { String identifier = chunk.getIdentifier(); Map<String, Object> params = new HashMap<String, Object>(); params.put("EQ_identifier", identifier); List<ErpChunk> erpChunks = chunkService.find(params); Set<Integer> chunkNumbers = new HashSet<Integer>(); for (ErpChunk erpChunk : erpChunks) { chunkNumbers.add(erpChunk.getChunkNumber()); } Map<String, Object> res = new HashMap<String, Object>(); res.put("chunkNumbers", chunkNumbers); params = new HashMap<String, Object>(); params.put("EQ_fileMd5", identifier); List<ErpFiles> files = filesService.find(params); if (files.size() > 0) { res.put("code", 200); res.put("fileName", files.get(0).getFileName()); res.put("fileUrl", files.get(0).getFileUrl()); res.put("fileSize", files.get(0).getFileSize()); }else if (erpChunks.size() > 0 && erpChunks.size() == erpChunks.get(0).getTotalChunks()) { res.put("message", "上传成功!"); res.put("code", 205); } ret.setResult(res); } catch (Exception e) { ret.setCode("error"); ret.setMsg("操作失败\n\n详细信息:" + e.getMessage()); e.printStackTrace(); } return ret; } /** * 分块上传 * * @param chunk * @return */ /** * @param chunk * @return */ @RequestMapping(value = "/chunk", method = RequestMethod.POST) @ResponseBody public GooidServerSay saveChunk(ErpChunk chunk) { GooidServerSay ret = new GooidServerSay(); try { // 这里的操作和保存单段落的基本是一致的~ MultipartFile file = chunk.getFile(); String identifier = chunk.getIdentifier(); byte[] bytes; try { bytes = file.getBytes(); // 这里的不同之处在于这里进行了一个保存分块时将文件名的按照-chunkNumber的进行保存 Path path = Paths .get(generatePath(uploadFolder + LoginController.getUser().getUsername() + "/", chunk)); Files.write(path, bytes); } catch (IOException e) { e.printStackTrace(); } if (chunk.getFilename().lastIndexOf(".") > 0) { chunk.setType(chunk.getFilename().substring(chunk.getFilename().lastIndexOf("."))); } chunkService.save(chunk); // 这里进行的是保存到redis,并返回集合的大小的操作 Map<String, Object> params = new HashMap<String, Object>(); params.put("EQ_identifier", identifier); int chunks = chunkService.find(params).size(); Map<String, Object> result = new HashMap<>(); // 如果集合的大小和totalChunks相等,判定分块已经上传完毕,进行merge操作 if (chunks == chunk.getTotalChunks().intValue()) { result.put("message", "上传成功!"); result.put("code", 205); } ret.setResult(result); } catch (Exception e) { e.printStackTrace(); ret.setCode("error"); ret.setMsg("操作失败\n\n详细信息:" + e.getMessage()); } return ret; } /** * 生成分块的文件路径 */ private static String generatePath(String uploadFolder, ErpChunk 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) { e.printStackTrace(); } } // 返回以 - 隔离的分块文件,后面跟的chunkNumber方便后面进行排序进行merge return sb.append(File.separator).append(chunk.getFilename()).append("-").append(chunk.getChunkNumber()) .toString(); } @RequestMapping(value = "/merge", method = RequestMethod.POST) @ResponseBody public GooidServerSay mergeChunks(ErpChunk chunk) { GooidServerSay ret = new GooidServerSay(); try { // 如果合并后的路径不存在,则新建 if (!Files.isWritable(Paths.get(uploadFolder + LoginController.getUser().getUsername() + "/"))) { Files.createDirectories(Paths.get(uploadFolder + LoginController.getUser().getUsername() + "/")); } // 合并的文件名 String target = uploadFolder + LoginController.getUser().getUsername() + "/" + File.separator + chunk.getIdentifier() + (chunk.getFilename().lastIndexOf(".") > 0 ? chunk.getFilename().substring(chunk.getFilename().lastIndexOf(".")) : ""); File file = new File(target); if (!file.exists()) { // 创建文件 Files.createFile(Paths.get(target)); // 遍历分块的文件夹,并进行过滤和排序后以追加的方式写入到合并后的文件 Files.list(Paths.get(uploadFolder + LoginController.getUser().getUsername() + "/" + File.separator + chunk.getIdentifier())) // 过滤带有"-"的文件 .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(); } }); File file1 = new File(uploadFolder + LoginController.getUser().getUsername() + "/" + File.separator + chunk.getIdentifier()); if (!file1.exists()) { file1.delete(); } } ErpFiles files = new ErpFiles(); files.setFileName(chunk.getFilename()); files.setCreationTime(new Date()); files.setFileMd5(chunk.getIdentifier()); files.setFileSize(chunk.getTotalSize()); if (chunk.getFilename().lastIndexOf(".") > 0) { files.setFileType(chunk.getFilename().substring(chunk.getFilename().lastIndexOf("."))); } files.setFileUrl("/erp/swagger/uploadFiles1/" + LoginController.getUser().getUsername() + "/" + chunk.getIdentifier() + chunk.getType()); files.setUserName(LoginController.getUser().getUsername()); filesService.save(files); Map<String, String> result = new HashMap<String, String>(); result.put("fileName", chunk.getFilename()); result.put("fileUrl", files.getFileUrl()); result.put("fileSize", chunk.getTotalSize() + ""); ret.setResult(result); } catch (IOException e) { e.printStackTrace(); ret.setCode("error"); ret.setMsg("操作失败\n\n详细信息:" + e.getMessage()); } return ret; }
参考链接:https://www.jianshu.com/p/de91ed955427

浙公网安备 33010602011771号