前言
分片: 分片任务是在前端由vue-simple-uploader
插件完成,流程:1.前端先发送check-file(检查文件MD5)来确认文件是直接秒传还是分片上传,如果计算出文件所有片已经上传完成,则启用秒传(秒传就是不传),如果是新文件,则需要分片上传,由vue-simple-uploader
插件将文件按固定大小进行切割,然后逐片上传。
断点续传: 意思就是一个大文件分了多少片,这些片已经上传了哪些,还有哪些没上传,这些都会记录在文件存储目录下的.conf
文件中,当你上传大文件时,传一部分后刷新浏览器或关闭浏览器,这时候传输会中断,然后你再打开页面重新上传该文件,它会先检测还有哪些片没有上传,然后直接上传的上次未传的片,这就是断点续传。
秒传: 文件不经过上传的步骤,直接将文件信息保存在服务器中。通过计算文件md5实现
流程
- 校验文件上传状态: 前端生成该文件的MD5密文并进行分片,上传之前请求check-md5接口,传入文件名和密文,接口校验文件是
未上传
或上传了一部分
或已上传完成
三个状态,其中未上传返回自定义状态码404
,上传一部分则返回状态206+未上传的分片ID
,上传完成则返回状态200
。 - 前端逐片上传: 校验完成后,根据校验结果对未上传的分片进行逐个上传,上传分片时参数主要是:
总片数、当前片ID、片文件
- 上传接口: 上传接口会先去获取并解析该文件的conf文件(conf文件是RandomAccessFile,该类是通过提供指针的方式操作文件,文件存储的是一个二进制数组,所以可以用来数组下标标记片ID),使用setLength方法设置conf文件长度,使用seek方法跳到当前上传的片ID的位置,把该位置的值替换成127,然后将该分片使用指针偏移的方式插入到_tmp临时文件(临时文件也是RandomAccessFile文件)中,然后校验是否所有的片都上传完成,是则修改临时文件为正式文件名,至此上传完成,否则直接返回该分片上传完成
- 上传进度: 前端收到当前片的响应结果后,会根据已上传片数量获取到上传进度
- MD5的用法: 用于计算服务器是否已经存在相同md5的文件,用作秒传功能的实现。前端计算文件md5,传入后端进行查找是否已经有相同md5文件,若存在直接返回上传成功,否则走上传的步骤
后端接口
Controller
/**
* 检查文件MD5(文件MD5若已存在进行秒传)
*
* @param md5 md5
* @param fileName 文件名称
* @return {@link ApiResponse}
* @author 7bin
**/
@GetMapping(value = "/check")
public ApiResponse checkFileMd5(String md5, String fileName) {
// Result result = fileService.checkFileMd5(md5, fileName);
// return NovelWebUtils.forReturn(result);
return ApiResponse.success();
}
/**
* 断点续传方式上传文件:用于大文件上传
*
* @param chunkDTO 参数
* @param request 请求
* @return {@link ApiResponse}
* @author 7bin
**/
@PostMapping(value = "/breakpoint-upload", consumes = "multipart/*", headers = "content-type=multipart/form-data", produces = "application/json;charset=UTF-8")
public ApiResponse breakpointResumeUpload(Chunk chunkDTO, HttpServletRequest request) {
String id = chunkDTO.getIdentifier();
int chunks = Math.toIntExact(chunkDTO.getTotalChunks());
int chunk = chunkDTO.getChunkNumber() - 1;
long size = chunkDTO.getCurrentChunkSize();
String name = chunkDTO.getFilename();
MultipartFile file = chunkDTO.getFile();
String md5 = chunkDTO.getIdentifier();
UploadFileParam param = new UploadFileParam(id, chunks, chunk, size, name, file, md5);
// return ApiResponse.success();
Result result = fileService.breakpointResumeUpload(param, request);
return NovelWebUtils.forReturn(result);
}
/**
* 检查文件MD5(文件MD5若已存在进行秒传)
* @param chunkMap
* @return {@link ApiResponse}
* @author 7bin
**/
@GetMapping(value = "/breakpoint-upload")
public ApiResponse breakpointResumeUploadPre(
@RequestParam Map<String, String> chunkMap) {
String md5 = chunkMap.get("identifier");
String filename = chunkMap.get("filename");
Result<JSONArray> result = fileService.checkFileMd5(md5, filename);
JSONObject res = new JSONObject();
// 数据库中存在该md5则秒传
if (result == null){
res.put("skipUpload",true);
return ApiResponse.success(res);
}
boolean skipUpload = false;
if ("200".equals(result.getCode()) || "201".equals(result.getCode())) {
skipUpload = true;
} else if ("206".equals(result.getCode())) {
// 已经上传部分分块
// data中存放的是还未上传的分块
JSONArray data = result.getData();
res.put("missChunks",data);
}
res.put("skipUpload",skipUpload);
return ApiResponse.success(res);
// Result result = fileService.breakpointResumeUpload(param, request);
// return NovelWebUtils.forReturn(result);
}
Service
@Override
public Result<JSONArray> checkFileMd5(String md5, String fileName) {
boolean exist = fileMapper.fileIsExist(md5);
if (exist){
return null;
}
Result<JSONArray> result;
try {
// String realFilename = md5 + "_" + fileName;
String realFilename = md5;
result = LocalUpload.checkFileMd5(md5, realFilename, confFilePath, savePath);
} catch (Exception e) {
// e.printStackTrace();
log.error(e.getMessage());
throw new ServiceException(e.getMessage());
}
return result;
}
@Override
public Result breakpointResumeUpload(UploadFileParam param, HttpServletRequest request) {
Result result;
try {
// 这里的 chunkSize(分片大小) 要与前端传过来的大小一致
// long chunkSize = Objects.isNull(param.getChunkSize()) ? 5 * 1024 * 1024
// : param.getChunkSize();
// 实际存储的文件格式为 [{md5}_{filename}]
// String realFilename = param.getMd5() + "_" + param.getName();
String realFilename = param.getMd5();
param.setName(realFilename);
result = LocalUpload.fragmentFileUploader(param, confFilePath, savePath, 5242880L, request);
// return NovelWebUtils.forReturn(result);
} catch (Exception e) {
log.error(e.getMessage());
throw new ServiceException(e.getMessage());
}
return result;
}
前端代码
SimpleUploader.vue
<template>
<div id="global-uploader">
<uploader
class="uploader-app"
:options="initOptions"
:file-status-text="fileStatusText"
:auto-start="false"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-progress="onFileProgress"
@file-error="onFileError"
>
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<uploader-btn >选择文件</uploader-btn>
<span style="margin-left: 10px">(支持上传一个或多个文件)</span>
<!--<uploader-btn directory>上传文件夹 </uploader-btn>-->
</uploader-drop>
<!--<uploader-btn id="global-uploader-btn" ref="uploadBtnRef">选择文件</uploader-btn>-->
<!--<span>(支持上传一个或多个文件)</span>-->
<uploader-list>
<template #default="{ fileList }">
<div class="file-panel">
<!--<div class="file-title">-->
<!-- <div class="title">文件列表</div>-->
<!--</div>-->
<ul class="file-list">
<li
v-for="file in fileList"
:key="file.id"
class="file-item"
>
<uploader-file
ref="files"
:class="['file_' + file.id, customStatus]"
:file="file"
:list="true"
></uploader-file>
</li>
<div v-if="!fileList.length" class="no-file">
<!--<Icon icon="ri:file-3-line" width="16" /> 暂无待上传文件-->
暂无待上传文件
</div>
</ul>
</div>
</template>
</uploader-list>
</uploader>
</div>
</template>
<script setup>
import useCurrentInstance from "@/utils/currentInstance";
import { generateMD5 } from "@/components/Uploader/utils/md5";
import { ElNotification } from "element-plus";
import { addFileToDrive } from "@/api/drive/drive";
import { checkAuth } from "@/api/admin/user";
const { proxy } = useCurrentInstance();
// TODO 上传组件还有bug 上传成功时动作按钮没有隐藏;后端出现错误上传失败时背景色没变红
// props
// emits
const emits = defineEmits(['uploadSuccess']);
const drivePath = import.meta.env.VITE_APP_DRIVE_API;
const initOptions = {
target: drivePath + "/file/breakpoint-upload",
chunkSize: '5242880',
forceChunkSize: true,
fileParameterName: 'file',
maxChunkRetries: 3,
// 是否开启服务器分片校验
testChunks: true,
// 服务器分片校验函数,秒传及断点续传基础
checkChunkUploadedByResponse: function (chunk, message) {
let skip = false
// console.log("checkChunkUploadedByResponse chunk:", chunk);
// console.log("checkChunkUploadedByResponse message:", message);
try {
let objMessage = JSON.parse(message)
// console.log("objMessage:", objMessage);
if (objMessage.code === 200) {
if (objMessage.data.skipUpload) {
skip = true
} else if (objMessage.data.missChunks == null){
skip = false;
} else {
skip = (objMessage.data.missChunks || []).indexOf(chunk.offset.toString()) < 0
}
}
} catch (e) {}
// console.log("skip: " + chunk.offset + " " + skip);
return skip
},
query: (file, chunk) => {
// console.log("query:", file);
return {
...file.params
}
}
}
const customStatus = ref('')
const fileStatusText = {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '已暂停',
waiting: '等待上传'
}
// const uploaderRef = ref()
// const uploader = computed(() => uploaderRef.value?.uploader)
async function onFileAdded(file) {
// 判断用户是否已经登录了,登录才可以添加
await checkAuth();
// 暂停文件
// 选择文件后暂停文件上传,上传时手动启动
file.pause()
// console.log("onFileAdded file: ", file);
// panelShow.value = true
// trigger('fileAdded')
// 将额外的参数赋值到每个文件上,以不同文件使用不同params的需求
// file.params = customParams.value
// 计算MD5
const md5 = await computeMD5(file)
startUpload(file, md5)
}
function computeMD5(file) {
// 文件状态设为"计算MD5"
statusSet(file.id, 'md5')
// 计算MD5时隐藏"开始"按钮
nextTick(() => {
// document.querySelector(`.file_${file.id} .uploader-file-resume`).style.display = 'none'
document.querySelector(`.file_${file.id} .uploader-file-actions`).style.display = 'none'
})
// 开始计算MD5
return new Promise((resolve, reject) => {
generateMD5(file, {
onProgress(currentChunk, chunks) {
// 实时展示MD5的计算进度
nextTick(() => {
const md5ProgressText = '校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%'
document.querySelector(`.custom-status-${file.id}`).innerText = md5ProgressText
})
},
onSuccess(md5) {
statusRemove(file.id)
resolve(md5)
},
onError() {
error(`文件${file.name}读取出错,请检查该文件`)
file.cancel()
statusRemove(file.id)
reject()
}
})
})
}
// md5计算完毕,开始上传
function startUpload(file, md5) {
file.uniqueIdentifier = md5
file.resume()
}
function onFileProgress(rootFile, file, chunk) {
console.log(
`上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${
chunk.endByte / 1024 / 1024
}`
)
}
const onFileError = (rootFile, file, response, chunk) => {
// console.log('error', file)
error(response)
}
function error(msg) {
ElNotification({
title: '错误',
message: msg,
type: 'error',
duration: 2000
})
}
const onFileSuccess = (rootFile, file, response, chunk) => {
// console.log("上传成功")
// console.log("rootFile",rootFile)
// file的relativePath是文件夹的相对路径(如果上传的是文件夹的话)
// console.log("file",file)
// console.log("response",JSON.parse(response))
// console.log("chunk",chunk)
// addFileToDrive(file.name, file.uniqueIdentifier, file.size).then(() => {
// proxy.$modal.msgSuccess("文件上传成功");
// })
// 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
let res = JSON.parse(response)
console.log("onFileSuccess res:", res);
if (res.code !== 200) {
error(res.message)
// 文件状态设为“失败”
statusSet(file.id, 'failed')
return
}
emits("uploadSuccess", file);
}
/**
* 新增的自定义的状态: 'md5'、'merging'、'transcoding'、'failed'
* @param id
* @param status
*/
function statusSet(id, status) {
const statusMap = {
md5: {
text: '校验MD5',
bgc: '#fff'
},
failed: {
text: '上传失败',
bgc: '#e2eeff'
}
}
customStatus.value = status
nextTick(() => {
const statusTag = document.createElement('span')
statusTag.className = `custom-status-${id} custom-status`
statusTag.innerText = statusMap[status].text
statusTag.style.backgroundColor = statusMap[status].bgc
// custom-status 样式不生效
// 由于 style脚本 设置了 scoped,深层的样式修改不了
// 通过给当前组件设置一个id,在该id下设置样式,就可以保证样式不全局污染
// statusTag.style.position = 'absolute';
// statusTag.style.top = '0';
// statusTag.style.left = '0';
// statusTag.style.right = '0';
// statusTag.style.bottom = '0';
// statusTag.style.zIndex = '1';
const statusWrap = document.querySelector(`.file_${id} .uploader-file-status`)
statusWrap.appendChild(statusTag)
})
}
function statusRemove(id) {
customStatus.value = ''
nextTick(() => {
const statusTag = document.querySelector(`.custom-status-${id}`)
document.querySelector(`.file_${id} .uploader-file-actions`).style.display = 'block'
statusTag.remove()
})
}
</script>
md5.js
import SparkMD5 from 'spark-md5'
/**
* 分段计算MD5
* @param file {File}
* @param options {Object} - onProgress | onSuccess | onError
*/
export function generateMD5(file, options = {}) {
const fileReader = new FileReader()
const time = new Date().getTime()
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkSize = 10 * 1024 * 1000
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const loadNext = () => {
let start = currentChunk * chunkSize
let end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
}
loadNext()
fileReader.onload = (e) => {
spark.append(e.target.result)
if (currentChunk < chunks) {
currentChunk++
loadNext()
if (options.onProgress && typeof options.onProgress == 'function') {
options.onProgress(currentChunk, chunks)
}
} else {
let md5 = spark.end()
// md5计算完毕
if (options.onSuccess && typeof options.onSuccess == 'function') {
options.onSuccess(md5)
}
console.log(
`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${
new Date().getTime() - time
} ms`
)
}
}
fileReader.onerror = function () {
console.log('MD5计算失败')
if (options.onError && typeof options.onError == 'function') {
options.onError()
}
}
}