大文件分片上传断点续传解决方案
大文件分片上传断点续传解决方案
记录一次大文件上传,文件为巨量的图片压缩包,因为图片是需要预览的,所以每张都需要传到minio去,不能采用minio的分片上传方式,这个方式只能把压缩包上传到minio,无法实现预览。
后来逻辑为:先分片上传到服务器临时文件夹,然后后端解析,一张张传到minio,当然传到minio需要异步执行。
一、分片上传压缩包到服务器
需要前后端配合:前端获取文件的hash值,然后把文件按照一定大小分片,循环调用接口,上传到服务器,最后发送合并接口,合并的依据就是文件的hash值。
前端主要逻辑:
methods: {
// 上传完文件触发
async handleChange(file) {
if (!file) return
this.percent = 0
this.percentCount = 0
this.videoUrl = ''
// 获取文件并转成 ArrayBuffer 对象
const fileObj = file.raw
this.file = fileObj.name
let buffer
try {
buffer = await this.fileToBuffer(fileObj)
} catch (e) {
console.log(e)
}
// 将文件按固定大小(10M)进行切片,注意此处同时声明了多个常量
const chunkSize = 10485760,
chunkList = [], // 保存所有切片的数组
chunkListLength = Math.ceil(fileObj.size / chunkSize), // 计算总共多个切片
suffix = /\.([0-9A-z]+)$/.exec(fileObj.name)[1] // 文件后缀名
// 根据文件内容生成 hash 值
const spark = new SparkMD5.ArrayBuffer()
spark.append(buffer)
const hash = spark.end()
// 生成切片,这里后端要求传递的参数为字节数据块(chunk)和每个数据块的文件名(fileName)
let curChunk = 0 // 切片时的初始位置
for (let i = 0; i < chunkListLength; i++) {
const item = {
chunk: fileObj.slice(curChunk, curChunk + chunkSize),
fileName: `${hash}_${i}.${suffix}` // 文件名规则按照 hash_1.jpg 命名
}
curChunk += chunkSize
chunkList.push(item)
}
this.chunkList = chunkList // sendRequest 要用到
this.hash = hash // sendRequest 要用到
this.sendRequest()
},
// 发送请求
sendRequest() {
const requestList = [] // 请求集合
let that = this
this.chunkList.forEach((item, index) => {
const fn = () => {
const formData = new FormData()
formData.append('chunk', item.chunk)
formData.append('filename', item.fileName)
return api.upload.chunk(formData).then(res => {
if (res.code === 200) { // 成功
if (that.percentCount === 0) { // 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
that.percentCount = 100 / that.chunkList.length
}
if (that.percent >= 100) {
that.percent = 100;
}else {
that.percent += that.percentCount // 改变进度
}
if (that.percent >= 100) {
that.percent = 100;
}
that.chunkList.splice(index, 1) // 一旦上传成功就删除这一个 chunk,方便断点续传
}
})
}
requestList.push(fn)
})
let i = 0 // 记录发送的请求个数
// 文件切片全部发送完毕后,需要请求 '/merge' 接口,把文件的 hash 传递给服务器
const complete = () => {
api.upload.merge(this.hash, this.file).then(res => {
if (res.code === 200) { // 请求发送成功
// this.videoUrl = res.data.path
console.log(res)
}
})
}
const send = async () => {
if (!this.upload) return
if (i >= requestList.length) {
// 发送完毕
complete()
return
}
await requestList[i]()
i++
send()
}
send() // 发送请求
},
// 按下暂停按钮
handleClickBtn() {
this.upload = !this.upload
// 如果不暂停则继续上传
if (this.upload) this.sendRequest()
},
// 将 File 对象转为 ArrayBuffer
fileToBuffer(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.readAsArrayBuffer(file)
fr.onload = e => {
resolve(e.target.result)
}
fr.onerror = () => {
reject(new Error('转换文件格式发生错误'))
}
})
}
}
后端主要逻辑
@PostMapping("chunk")
@ApiOperation("压缩包分片上传")
public Result<Object> chunkUpload(@RequestParam MultipartFile chunk,
@RequestParam String filename) {
File folder = new File(tmpPath);
if (!(folder.exists() && folder.isDirectory())) {
folder.mkdirs();
}
String filePath = tmpPath+File.separator+filename;
try {
File file = new File(filePath);
FileOutputStream fileOutputStream = new FileOutputStream(file);
fileOutputStream.write(chunk.getBytes());
fileOutputStream.close();
chunk.transferTo(file);
}catch (Exception e) {
e.printStackTrace();
}
return Result.OK("成功");
}
// ------------------------------------------------------------------------------------
@GetMapping("merge")
@ApiOperation("压缩包分片合并")
public Result<String> merge(@RequestParam String hash,
@RequestParam String filename) {
File chunkFileFolder = new File(tmpPath);
File mergeFile = new File(tmpPath + File.separator + filename);
File[] chunks = chunkFileFolder.listFiles();
Assert.notNull(chunks, "未上传分片");
File[] files = Arrays.stream(chunks)
.filter(file -> file.getName().startsWith(hash))
.sorted(Comparator.comparing(o -> Integer.valueOf(o.getName().split("\\.")[0].split("_")[1])))
.toArray(File[]::new);
try {
RandomAccessFile randomAccessFileWriter = new RandomAccessFile(mergeFile, "rw");
byte[] bytes = new byte[1024];
for (File chunk : files) {
RandomAccessFile randomAccessFileReader = new RandomAccessFile(chunk, "r");
int len;
while ((len = randomAccessFileReader.read(bytes)) != -1) {
randomAccessFileWriter.write(bytes, 0 ,len);
}
randomAccessFileReader.close();
}
randomAccessFileWriter.close();
// 删除分片
Arrays.stream(files).forEach(File::delete);
}catch (Exception e) {
e.printStackTrace();
}
return Result.OK("成功");
}
二、解析压缩包上传图片到minio
解析上传主要逻辑
先解压压缩包,然后验证文件夹层级的格式,然后递归遍历出所有的图片,然后循环上传至minio,用redis计数器记录上传图片的张数,最后把文件信息保存到数据库
/**
* 异步方法
* 有@Async注解的方法,默认就是异步执行的,会在默认的线程池中执行,但是此方法不能在本类调用;
* 启动类需添加直接开启异步执行@EnableAsync。
* @param filename zip
*/
@Override
@SneakyThrows
@Async("normalThreadPool")
public void parseAndUpload(String filename){
// D:\tmp\罗柏线.zip
String zipPath = path+File.separator+filename;
// 解压成 -> D:\tmp\罗柏线
String unzipPath = checkUnZip(zipPath);
// 递归遍历拿到所有文件
List<String> allPicAndFiles = new ArrayList<>();
visitAllDirAndFiles(unzipPath, allPicAndFiles);
// 检查格式
checkFormat(allPicAndFiles);
// 上传
try {
//查表 查fullPath判断是否上传过
List<ThirdImageInfo> list = baseMapper.selectList(new QueryWrapper<>());
if (!list.isEmpty()) {
List<String> uploaded = list.stream().map(ThirdImageInfo::getLocatePath).collect(Collectors.toList());
allPicAndFiles.removeIf(uploaded::contains);
}
redisUtil.set(ThirdConstant.REDIS_PIC_ALL_COUNT, allPicAndFiles.size());
redisUtil.set(ThirdConstant.REDIS_PIC_CURRENT_COUNT, allPicAndFiles.size());
List<ThirdImageInfo> infoList = new ArrayList<>();
for (String fullPath : allPicAndFiles) {
String objectName = fullPath.
replace(path, ThirdConstant.IMAGE_OBJECT_NAME_ROOT).
replace("\\", "/");
@Cleanup
FileInputStream stream = new FileInputStream(new File(fullPath));
minioUtil.uploadFile(
stream, defaultBucketName, objectName, FileUtil.getSuffix(fullPath)
);
redisUtil.decr(ThirdConstant.REDIS_PIC_CURRENT_COUNT, 1);
// 上传成功 增加一条记录
infoList.add(insertRecords(fullPath, objectName));
System.out.println(fullPath +" 上传成功");
}
this.saveBatch(infoList);
redisUtil.set(ThirdConstant.REDIS_PIC_ALL_COUNT, 0);
}catch (Exception e) {
e.printStackTrace();
}
}
完整代码上传至仓库,有时间再整理。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了