前端流程
Main组件中,包含“上传”按钮,点击上传按钮,会调用“addFile”方法,该方法会将文件和文件父目录id传递出去,emit出去。因为上传文件后,会弹出弹框,显示文件上传进度及状态,这个弹框在父组件中,所以我们emit出去文件数据,让父组件可以拿到。
子组件:(Main.vue)
父组件:(framework.vue)
这里的const {file,filePid} = data; 就拿到了当时子组件Framework.vue中传来的文件和文件父目录id。可以看到144行又调用了一个uploaderRef中的addFile方法,并把文件参数传入,可以知道,这应该就是具体的文件上传逻辑方法。
捋一下下:emit()
拿上栗说明,在子组件(Main组件)中定义了emit事件:const emit = defineEmits(["addFile"]);
点击上传按钮后,会调用“addFile”方法,方法中emit了"addFile",触发了emit事件,接着,就到达了父组件(Framework组件)中的@addFile处,父组件定义了@addFile="addFile",于是就接着调用了父组件中的"addFile"方法。
Framework.vue引入一Uploader.vue作子组件,具体上传文件逻辑都在该子组件中。
父组件(Framework.vue)
这里的el-popover组件即是上文说到的文件上传后会弹出的弹框。
e补一下:emit和expose的区别:
emit是子组件先,父组件后,子组件有相应操作后,emit出去,触发父组件的操作。而expose是父组件先,子组件后,父组件调用子组件的。将子组件的方法或属性暴露出去,供父组件调用。
子组件(Uploader.vue)
子组件的addFile方法,父组件想调用,要子组件expose出去。之后,父组件可以通过 ref 访问子组件实例并调用暴露的属性和方法。(如上图Framework.vue中给了Uploader组件一个ref:uploaderRef,然后在js中通过uploaderRef.value.addFile()调用了子组件中的addFile方法)
前端文件上传具体逻辑
前端计算文件md5值,并将其作为一个参数传递。后端据此实现秒传逻辑。若文件md5值不存在,证明该文件首次传输,老实上传;若已存在,则直接从数据库拷贝一份,实现秒传。
addFile():
const addFile = async (file, filePid) => {
const fileItem = {
//文件,文件大小,文件流,文件名...
file: file,
//文件UID
uid: file.uid,
//md5进度
md5Progress: 0,
//md5值
md5: null,
//文件名
fileName: file.name,
//上传状态
status: STATUS.init.value,
//已上传文件大小
uploadSize: 0,
//文件总大小
totalSize: file.size,
//上传进度
uploadProgress: 0,
//暂停
pause: false,
//当前分片
chunkIndex: 0,
//文件父级ID
filePid: filePid,
//错误信息
errorMsg: null,
};
//让fileItem在fileList最顶端
//unshift():在数组头部添加内容
fileList.value.unshift(fileItem);
//文件为空
if(fileItem.totalSize == 0){
fileItem.status = STATUS.emptyfile.value;
return;
}
//md5
let md5FileUid = await computeMd5(fileItem);
if(md5FileUid == null){
return;
}
//上传文件
uploadFile(md5FileUid);
};
computeMd5 ():
//计算Md5
const computeMd5 = (fileItem) => {
let file = fileItem.file;
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
//分片数,向上取整
let chunks = Math.ceil(file.size / chunkSize);
//当前分片
let currentChunk = 0;
let spark = new SparkMD5.ArrayBuffer();
let fileReader = new FileReader();
let loadNext = () => {
let start = currentChunk * chunkSize; //开始节点
//每个分片是5M,到最后一分片时,可能是小于5M的,这时如果再加5M,就会超出文件大小,报错
//所以当开始节点加上分片大小大于文件大小时,结束节点就是文件大小了。
let end = start + chunkSize >= file.size ? file.size : start + chunkSize; //结束节点
//开读
fileReader.readAsArrayBuffer(blobSlice.call(file,start,end));
}
loadNext();
return new Promise((resolve,reject) => {
let resultFile = getFileByUid(file.uid);
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if(currentChunk < chunks){ //不是最后一片
console.log(
`${file.name},第${currentChunk}分片解析完成,开始第${currentChunk + 1}`
);
//进度条
let percent = Math.floor((currentChunk / chunks) * 100);
resultFile.md5Progress = percent;
//递归去跑
loadNext();
}else{ //最后一片
let md5 = spark.end(); //拿到md5值
spark.destroy();
resultFile.md5Progress = 100; //进度拉满
resultFile.status = STATUS.uploading.value; //解析完毕了,状态改为上传中。
resultFile.md5 = md5; //设置md5值
resolve(fileItem.uid);
}
};
fileReader.onerror = () => {
resultFile.md5Progress = -1;
resultFile.status = STATUS.fail.value;
resolve(fileItem.uid);
}
}).catch((error) => {
return null;
});
};
e补一下:Promise()
参数(resolve,reject):调用resolve()表示成功;调用reject()表示失败。
addFile()中调用了computeMd5(),在computeMd5()中,计算出了文件的md5值,并赋值给了文件的ms5属性,最后返回了文件的id。在该方法执行时,页面上显示的是文件解析中,带解析进度条。接着调用uploadFile(),终于传到后端去了。
const emit = defineEmits(["uploadCallback"]);
//上传文件
//chunkIndex,分片下标,用于用户上传时暂停,再次上传,我们知道该从哪片开始上传
const uploadFile = async (uid,chunkIndex) => {
//chunkIndex是否存在,不存在取0
chunkIndex = chunkIndex ? chunkIndex : 0;
//分片上传
let currentFile = getFileByUid(uid);
const file = currentFile.file;
const fileSize = currentFile.totalSize;
const chunks = Math.ceil(fileSize / chunkSize);
for(let i = chunkIndex;i < chunks;i++){
//在分片上传过程中,用户删除了该操作,就中止上传
//具体操作是:拿当前文件id去删除列表找,如果找到了,就是用户删除了该操作
//indexOf() 返回-1,证明没找到
//注意:在某一分片上传时,删除,要等到该分片上传完,到下一分片才会中止操作。
let delIndex = delList.value.indexOf(uid);
if(delIndex != -1){ //找到了
//删除
delList.value.splice(delIndex,1);
break;
}
currentFile = getFileByUid(uid);
if(currentFile.pause){
break;
}
let start = i * chunkSize;
let end = start + chunkSize >= fileSize ? fileSize : start + chunkSize;
let chunkFile = file.slice(start,end);
let updateResult = await proxy.Request({
url:api.upload,
showLoading:false,
dataType:"file",
params:{
file:chunkFile,
fileName:file.name,
fileMd5:currentFile.md5,
chunkIndex:i,
chunks:chunks,
fileId:currentFile.fileId,
filePid:currentFile.filePid,
},
showError:false,
errorCallback:(errorMsg) => {
currentFile.status = STATUS.fail.value;
currentFile.errorMsg = errorMsg;
},
uploadProgressCallback:(event) => {
let loaded = event.loaded;
if(loaded > fileSize){
loaded = fileSize;
}
currentFile.uploadSize = i * chunkSize + loaded;
currentFile.uploadProgress = Math.floor((currentFile.uploadSize / fileSize) * 100);
},
});
if(updateResult == null){
break;
}
currentFile.fileId = updateResult.data.fileId;
currentFile.status = STATUS[updateResult.data.status].value;
currentFile.chunkIndex = i;
if(updateResult.data.status == STATUS.upload_seconds.value ||
updateResult.data.status == STATUS.upload_finish.value
){
currentFile.uploadProgress = 100;
emit("uploadCallback");
break;
}
}
};
文件上传完后,触发emit事件“uploadCallback”,会到framework.vue父组件中调用该方法。
后端时间
后端先将所有分片存至一个临时目录,等所有分片上传完成后,合并为一个文件,放入存储地,并将文件信息入数据库,更新用户空间。
/**
* 文件分片上传
*
* @param webUserDto 用户信息
* @param fileId 文件id
* @param file 文件
* @param fileName 文件名
* @param filePid 文件父级目录
* @param fileMd5 文件md5值,由前端传来
* @param chunkIndex 分片顺序
* @param chunks 总分片数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public UploadResultDto uploadFile(SessionWebUserDto webUserDto, String fileId, MultipartFile file, String fileName, String filePid, String fileMd5, Integer chunkIndex, Integer chunks) {
UploadResultDto resultDto = new UploadResultDto();
Boolean uploadSuccess = true;
File tempFileFolder = null;
try {
if (StringTools.isEmpty(fileId)) {
fileId = StringTools.getRandomString(Constants.LENGTH_10);
}
resultDto.setFileId(fileId);
Date curDate = new Date();
UserSpaceDto spaceDto = redisComponent.getUserSpaceUse(webUserDto.getUserId());
//若当前是第一分片,需要判断md5值,如果数据库中存在,证明数据库里有这个文件,直接在数据库里copy一份,实现秒传
//若没有,则需要上传,设置md5值。
if (chunkIndex == 0) {
FileInfoQuery infoQuery = new FileInfoQuery();
infoQuery.setFileMd5(fileMd5);
infoQuery.setSimplePage(new SimplePage(0, 1)); //查一条
infoQuery.setStatus(FileStatusEnums.USING.getStatus());
//查询数据库里是否有这个文件
List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
//如果存在,不用再次上传,实现秒传,直接在数据库里copy一份给这个用户
//秒传
if (!dbFileList.isEmpty()) {
FileInfo dbFile = dbFileList.get(0);
//判断文件大小,若超过,不给传
if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
dbFile.setFileId(fileId);
dbFile.setFilePid(filePid);
dbFile.setUserId(webUserDto.getUserId());
dbFile.setCreateTime(curDate);
dbFile.setLastUpdateTime(curDate);
dbFile.setStatus(FileStatusEnums.USING.getStatus());
dbFile.setDelFlag(FileDelFlagEnums.USING.getFlag());
//文件名
fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
dbFile.setFileName(fileName);
this.fileInfoMapper.insert(dbFile);
resultDto.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());
//更新用户使用空间
updateUserSpace(webUserDto, dbFile.getFileSize());
return resultDto;
}
}
//判断磁盘空间
Long currentTempSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
if (file.getSize() + currentTempSize + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
throw new BusinessException(ResponseCodeEnum.CODE_904);
}
//暂存临时目录
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
String currentUserFolderName = webUserDto.getUserId() + fileId;
tempFileFolder = new File(tempFolderName + currentUserFolderName);
if (!tempFileFolder.exists()) {
tempFileFolder.mkdirs();
}
File newFile = new File(tempFileFolder + "/" + chunkIndex);
file.transferTo(newFile);
//保存临时大小
redisComponent.saveFileTempSize(webUserDto.getUserId(), fileId, file.getSize());
if (chunkIndex < chunks - 1) { //不是最后分片
resultDto.setStatus(UploadStatusEnums.UPLOADING.getCode());
return resultDto;
}
//最后一个分片上传完成,记录数据库,异步合并分片
String month = DateUtils.format(new Date(), DateTimePatternEnum.YYYYMM.getPattern());
String fileSuffix = StringTools.getFileSuffix(fileName);
//真实文件名
String realFileName = currentUserFolderName + fileSuffix;
FileTypeEnums fileTypeEnums = FileTypeEnums.getFileTypeBySuffix(fileSuffix);
//自动重命名
fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
//入数据库
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(fileId);
fileInfo.setUserId(webUserDto.getUserId());
fileInfo.setFileMd5(fileMd5);
fileInfo.setFileName(fileName);
fileInfo.setFilePath(month + "/" + realFileName);
fileInfo.setFilePid(filePid);
fileInfo.setCreateTime(curDate);
fileInfo.setLastUpdateTime(curDate);
fileInfo.setFileCategory(fileTypeEnums.getCategory().getCategory());
fileInfo.setFileType(fileTypeEnums.getType());
fileInfo.setStatus(FileStatusEnums.TRANSFER.getStatus());
fileInfo.setFolderType(FileFolderTypeEnums.FILE.getType());
fileInfo.setDelFlag(FileDelFlagEnums.USING.getFlag());
this.fileInfoMapper.insert(fileInfo);
//该文件总空间
Long totalSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
updateUserSpace(webUserDto, totalSize);
resultDto.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());
//事务提交后,再调合并方法。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
fileInfoService.transferFile(fileInfo.getFileId(), webUserDto);
}
});
return resultDto;
} catch (BusinessException e) {
logger.error("文件上传失败", e);
uploadSuccess = false;
throw e;
} catch (Exception e) {
logger.error("文件上传失败", e);
uploadSuccess = false;
} finally {
//如果有异常,删除当前临时目录
if (!uploadSuccess) {
try {
FileUtils.deleteDirectory(tempFileFolder);
} catch (IOException e) {
logger.error("删除临时目录失败", e);
}
}
}
return resultDto;
}
transferFile()调用union()进行合并。
合并分片方法:union()
/**
* 合并分片
*
* @param dirPath 分片存放临时目录
* @param toFilePath 目标目录
* @param fileName 文件名
* @param delSource 是否删除源文件
*/
private void union(String dirPath, String toFilePath, String fileName, Boolean delSource) {
File dir = new File(dirPath);
if (!dir.exists()) {
throw new BusinessException("分片存放目录不存在");
}
File[] fileList = dir.listFiles(); //临时目录下的所有分片
File targetFile = new File(toFilePath);
RandomAccessFile writeFile = null;
try {
writeFile = new RandomAccessFile(targetFile, "rw");
byte[] b = new byte[1024 * 10];
for (int i = 0; i < fileList.length; i++) {
int len = -1;
File chunkFile = new File(dirPath + "/" + i); //分片文件
RandomAccessFile readFile = null;
try {
readFile = new RandomAccessFile(chunkFile, "r");
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
} catch (Exception e) {
logger.error("合并分片失败", e);
throw new BusinessException("合并分片失败");
} finally {
readFile.close();
}
}
} catch (Exception e) {
logger.error("合并文件:{}失败", fileName, e);
throw new BusinessException("合并文件" + fileName + "出错了");
} finally {
if (writeFile != null) {
try {
writeFile.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (delSource && dir.exists()) {
try {
FileUtils.deleteDirectory(dir);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
大致结束了。此外,在transferFile()中还使用了ffmpeg工具生成视频文件和图片文件的缩略图,为在文件列表中展示小图。