名词介绍:
- 文件上传 小文件(图片、文档、视频)上传可以直接使用很多ui框架封装的上传组件,或者自己写一个input 上传,利用FormData 对象提交文件数据,后端使用spring提供的MultipartFile进行文件的接收,然后写入即可。但是对于比较大的文件,比如上传2G左右的文件(http上传),就需要将文件分片上传(file.slice()),否则中间http长时间连接可能会断掉。
- 分片上传 分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件。
- 秒传 通俗的说,你把要上传的东西上传,
会先做MD5校验,如果服务器上有一样的东西,它就直接给你个新地址,其实你下载的都是服务器上的同一个文件,想要不秒传,其实只要让MD5改变,就是对文件本身做一下修改(改名字不行),例如一个文本文件,你多加几个字,MD5就变了,就不会秒传了. - 断点续传 断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。本文的断点续传主要是针对断点上传场景。
实现方案:
springBoot+vue-simple-uploader
vue-simple-uploader是基于 simple-uploader.js 封装的vue上传插件。它的优点包括且不限于以下几种:
- 支持文件、多文件、文件夹上传;支持拖拽文件、文件夹上传
- 可暂停、继续上传
- 错误处理
- 支持“秒传”,通过文件判断服务端是否已存在从而实现“秒传”
- 分片上传
- 支持进度、预估剩余时间、出错自动重试、重传等操作
具体流程:
- 前端对文件进行MD5加密。
- 前端发送get请求,携带MD5加密参数。
- 后端根据加密参数校验分片数据在服务端是否完整。
- 如果完整则进行秒传,如果不完整或者无数据,返回给前端已经上产的分片,进行分片上传。
数据库设计:
1.文件存储:
2.分片存储(也可以不用,使用redis存储临时分片信息):
代码实现:
前端 基于vue-simple-uploade完成简单搭建:
<template>
<div class="container">
<div class="logo"><img src="@/assets/logo.png" /></div>
<uploader
ref="uploader"
:options="options"
:autoStart="false"
:file-status-text="fileStatusText"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-error="onFileError"
@file-progress="onFileProgress"
class="uploader-example"
>
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<p>拖动文件到这里上传</p>
<uploader-btn>选择文件</uploader-btn>
<uploader-btn :directory="true">选择文件夹</uploader-btn>
</uploader-drop>
<!-- uploader-list可自定义样式 -->
<!-- <uploader-list></uploader-list> -->
<uploader-list>
<div class="file-panel" :class="{ collapse: collapse }">
<div class="file-title">
<p class="file-list-title">文件列表</p>
<div class="operate">
<el-button
type="text"
@click="operate"
:title="collapse ? '折叠' : '展开'"
>
<i
class="icon"
:class="
collapse ? 'el-icon-caret-bottom' : 'el-icon-caret-top'
"
></i>
</el-button>
<el-button type="text" @click="close" title="关闭">
<i class="icon el-icon-close"></i>
</el-button>
</div>
</div>
<ul
class="file-list"
:class="
collapse ? 'uploader-list-ul-show' : 'uploader-list-ul-hidden'
"
>
<li v-for="file in uploadFileList" :key="file.id">
<uploader-file
:class="'file_' + file.id"
ref="files"
:file="file"
:list="true"
></uploader-file>
</li>
<div class="no-file" v-if="!uploadFileList.length">
<i class="icon icon-empty-file"></i> 暂无待上传文件
</div>
</ul>
<div>
</div>
</div>
</uploader-list>
</uploader>
</div>
</template>
<script>
import SparkMD5 from "spark-md5";
const FILE_UPLOAD_ID_KEY = "file_upload_id";
// 分片大小,20MB
const CHUNK_SIZE = 2 * 1024 * 1024;
export default {
data() {
return {
options: {
// 上传地址
target: "http://127.0.0.1:8025/file/upload",
// 是否开启服务器分片校验。默认为 true
testChunks: true,
// 真正上传的时候使用的 HTTP 方法,默认 POST
uploadMethod: "post",
// 分片大小
chunkSize: CHUNK_SIZE,
// 并发上传数,默认为 3
simultaneousUploads: 3,
/**
* 判断分片是否上传,秒传和断点续传基于此方法
* 这里根据实际业务来 用来判断哪些片已经上传过了 不用再重复上传了 [这里可以用来写断点续传!!!]
*/
checkChunkUploadedByResponse: (chunk, message) => {
let result = JSON.parse(message);
let data = result.data;
//如果是需要上传
if (!data.isUpload) {
return true;
}
// 判断文件或分片是否已上传,已上传返回 true
return (data.uploads || []).indexOf(chunk.offset + 1) >= 0;
},
parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {
//格式化时间
return parsedTimeRemaining
.replace(/\syears?/, "年")
.replace(/\days?/, "天")
.replace(/\shours?/, "小时")
.replace(/\sminutes?/, "分钟")
.replace(/\sseconds?/, "秒");
},
},
// 修改上传状态
fileStatusTextObj: {
success: "上传成功",
error: "上传错误",
uploading: "正在上传",
paused: "停止上传",
waiting: "等待中",
},
uploadIdInfo: null,
uploadFileList: [],
fileChunkList: [],
collapse: true,
};
},
created() {},
methods: {
onFileAdded(file, event) {
this.uploadFileList.push(file);
this.getFileMD5(file, (md5) => {
if (md5 != "") {
// 修改文件唯一标识
file.uniqueIdentifier = md5;
// 请求后台判断是否上传
// 恢复上传
file.resume();
}
});
},
onFileSuccess(rootFile, file, response, chunk) {
console.log("上传成功");
},
onFileError(rootFile, file, message, chunk) {
console.log("上传出错:" + message);
},
onFileProgress(rootFile, file, chunk) {
console.log(`当前进度:${Math.ceil(file._prevProgress * 100)}%`);
},
//全部开始
allStart() {
console.log(this.uploadFileList);
this.fileList.map((e) => {
if (e.paused) {
e.resume();
}
});
},
//全部停止
allStop() {
console.log(this.uploadFileList);
this.uploadFileList.map((e) => {
if (!e.paused) {
e.pause();
}
});
},
//移除全部
allRemove() {
this.uploadFileList.map((e) => {
e.cancel();
});
this.fileList = [];
},
// 计算文件的MD5值
getFileMD5(file, callback) {
let spark = new SparkMD5.ArrayBuffer();
let fileReader = new FileReader();
//获取文件分片对象(注意它的兼容性,在不同浏览器的写法不同)
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
// 当前分片下标
let currentChunk = 0;
// 分片总数(向下取整)
let chunks = Math.ceil(file.size / CHUNK_SIZE);
// MD5加密开始时间
let startTime = new Date().getTime();
// 暂停上传
file.pause();
loadNext();
// fileReader.readAsArrayBuffer操作会触发onload事件
fileReader.onload = function (e) {
// console.log("currentChunk :>> ", currentChunk);
spark.append(e.target.result);
if (currentChunk < chunks) {
currentChunk++;
loadNext();
} else {
// 该文件的md5值
let md5 = spark.end();
console.log(
`MD5计算完毕:${md5},耗时:${new Date().getTime() - startTime} ms.`
);
// 回调传值md5
callback(md5);
}
};
fileReader.onerror = function () {
this.$message.error("文件读取错误");
file.cancel();
};
// 加载下一个分片
function loadNext() {
const start = currentChunk * CHUNK_SIZE;
const end =
start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;
// 文件分片操作,读取下一分片(fileReader.readAsArrayBuffer操作会触发onload事件)
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},
fileStatusText(status, response) {
if (status === "md5") {
return "校验MD5";
} else {
return this.fileStatusTextObj[status];
}
},
/**
* 折叠、展开面板动态切换
*/
operate() {
if (this.collapse === false) {
this.collapse = true;
} else {
this.collapse = false;
}
},
/**
* 关闭折叠面板
*/
close() {
this.uploaderPanelShow = false;
},
},
};
</script>
后端:
FileController:
主要两个方法:
1.检测:检测是否上传完成,检测已上传分片
2.上传:上传分片
@Slf4j
@Service
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final FileChunkMapper fileChunkMapper;
private final FileStorageMapper fileStorageMapper;
public String FILE_SAVE_PATH = "c:/java/study/code/uploaded";
public long DEFAULT_CHUNK_SIZE = 2 *1024 *1024;
@Override
public FileChunkVO checkFileUpload(FileChunkDTO fileChunkDTO) {
FileChunkVO result = new FileChunkVO();
//通过md5 查询该文件的分片 --分片库中存储的分片文件信息MD5一样
List<FileChunk> fileChunkList = fileChunkMapper.selectList(new LambdaQueryWrapper<FileChunk>()
.eq(FileChunk::getIdentifier, fileChunkDTO.getIdentifier()));
//未上传
if (CollUtil.isEmpty(fileChunkList)){
result.setIsUpload(true);
return result;
}
//判断是否是单文件-上传过
if(fileChunkList.get(0).getTotalChunks() == 1){
result.setIsUpload(false);
return result;
}
//分片信息
int[] uploads = new int[fileChunkList.size()];
int index = 0 ;
for (FileChunk fileChunk : fileChunkList) {
uploads[index] = fileChunk.getChunkNumber();
index++;
}
result.setIsUpload(true);
result.setUploads(uploads);
return result;
}
@Override
@Transactional
public Boolean fileUpload(FileChunkDTO fileChunkDTO) {
if (fileChunkDTO.getFile() == null){
throw new RuntimeException("上传的分片数据是null");
}
//文件存放目录,不存在就传还能
File file = new File(FILE_SAVE_PATH);
if (!file.exists()){
boolean isSuccess = file.mkdirs();
if (!isSuccess){
throw new RuntimeException("创建文件目录失败");
}
}
//生成文件名称
String fullFileName = file + File.separator + fileChunkDTO.getFilename();
//判断是否是单文件
if (fileChunkDTO.getTotalChunks() == 1){
//保存分片信息
fileChunkMapper.insert(BeanUtil.copyProperties(fileChunkDTO, FileChunk.class));
return this.saveFileStorage(fullFileName,fileChunkDTO);
}
//分片上传
// 分片上传,这里使用 uploadFileByRandomAccessFile 方法
boolean flag = uploadFileByRandomAccessFile(fullFileName, fileChunkDTO);
if (!flag) {
return false;
}
// 保存分片上传信息
fileChunkMapper.insert(BeanUtil.copyProperties(fileChunkDTO, FileChunk.class));
// 当文件分片完整上传完成,存一份在LocalStorage表中
if (fileChunkDTO.getChunkNumber().equals(fileChunkDTO.getTotalChunks())) {
this.saveFileStorage(fullFileName,fileChunkDTO);
}
return true;
}
private boolean uploadFileByRandomAccessFile(String fullFileName, FileChunkDTO fileChunkDTO) {
try (RandomAccessFile randomAccessFile = new RandomAccessFile(fullFileName, "rw")) {
// 分片大小必须和前端匹配,否则上传会导致文件损坏
long chunkSize = fileChunkDTO.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : fileChunkDTO.getChunkSize().longValue();
// 偏移量
long offset = chunkSize * (fileChunkDTO.getChunkNumber() - 1);
// 定位到该分片的偏移量
randomAccessFile.seek(offset);
// 写入
randomAccessFile.write(fileChunkDTO.getFile().getBytes());
} catch (IOException e) {
log.error("文件上传失败:" + e);
return false;
}
return true;
}
private Boolean saveFileStorage(String fullFileName, FileChunkDTO fileChunkDTO) {
File saveFile = new File(fullFileName);
try {
// 写入
fileChunkDTO.getFile().transferTo(saveFile);
this.saveLocalStorage(fileChunkDTO);
} catch (IOException e) {
log.error("文件上传失败:" + e);
return false;
}
return true;
}
private void saveLocalStorage(FileChunkDTO fileChunkDTO) {
Long id = null;
FileStorage fileStorage = fileStorageMapper.selectOne(new LambdaQueryWrapper<FileStorage>()
.eq(FileStorage::getIdentifier,fileChunkDTO.getIdentifier()));
if (!ObjectUtils.isEmpty(fileStorage)) {
id = fileStorage.getId();
}
String name = fileChunkDTO.getFilename();
String suffix = FileUtil.getExtensionName(name);
String type = FileUtil.getFileType(suffix);
FileStorage toSaveFile = FileStorage.builder()
.id(id).realName(name)
.name(FileUtil.getFileNameNoEx(name))
.suffix(suffix)
.type(type)
.size(FileUtil.getSize(fileChunkDTO.getTotalSize().longValue()))
.identifier(fileChunkDTO.getIdentifier())
.build();
fileStorageMapper.insert(toSaveFile);
}
}