步骤1:前端文件切片
步骤2-1:后端接口提供:切片文件,切片索引、切片数量【2选1】
步骤2-2:后端接口提供:切片文件包名、所有切片文件,切片数量【2选1】
步骤3:接收切片文件完成后并合并,且删除临时的切片文件
步骤4:上传合并的文件至文件服务Minio
步骤5:Minio返回上传文件地址,上传完成。
1、Controller层
package com.company.pvinspection.controller;
import com.company.common.core.baseweb.domain.AjaxResult;
import com.company.pvinspection.service.IFileUploadMinioService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 大文件上传
* 备注:大文件切片——切片文件临时存储——合并切片文件——上传MINIO
* 步骤1:前端将大文件分成n个小文件
* 步骤2:分别将小文件传给后端(包括:文件夹名、切片文件、文件名、切片总数)
* 当后端获取到最后一个分片文件时,合并分片文件,并上传至MINIO文件服务。【上传过程考虑断点续传】
*
* @author kally
* @date 2023/8/10
*/
@Api(tags = "大文件切片上传管理")
@RestController
@RequestMapping("/prj/uploadFile")
public class FileUploadController {
@Autowired
private IFileUploadMinioService fileService;
@ApiOperation(value = "切片文件单文件上传【使用】")
@PostMapping(value = "/upload")
public AjaxResult singleFilePartUpload(@ApiParam(value = "文件夹名称唯一") String folderName,
@ApiParam(value = "分片文件") MultipartFile filePart,
@ApiParam(value = "原始文件名") String fileName,
@ApiParam(value = "分片总数") Integer partNum,
@ApiParam(value = "作业ID") Long recordId) {
return fileService.singleFilePartUpload(folderName, filePart, fileName, partNum, recordId);
}
@ApiOperation(value = "切片文件所有文件上传【不用】")
@PostMapping(value = "/upload2")
public AjaxResult multipleFilePartUpload(@ApiParam(value = "文件夹名称唯一") String folderName,
@ApiParam(value = "分片文件") List<MultipartFile> fileParts,
@ApiParam(value = "原始文件名") String fileName,
@ApiParam(value = "分片总数") Integer partNum,
@ApiParam(value = "作业ID") Long recordId) {
return fileService.multipleFilePartUpload(folderName, fileParts, fileName, partNum, recordId);
}
}
2、Service层
package com.company.pvinspection.service;
import com.company.common.core.baseweb.domain.AjaxResult;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 文件上传接口
*
* @author kally
* @date 2023/8/10
*/
public interface IFileUploadMinioService {
/**
* 单文件分片上传
*
* @param folderName 文件夹名称唯一
* @param filePart 分片文件
* @param fileName 原始文件名
* @param partNum 分片数量
* @param recordId 作业ID
* @return 结果
*/
AjaxResult singleFilePartUpload(String folderName, MultipartFile filePart, String fileName, Integer partNum, Long recordId);
/**
* 多文件分片上传
*
* @param folderName 文件夹名称唯一
* @param fileParts 多个分片文件
* @param fileName 原始文件名
* @param partNum 分片数量
* @param recordId 作业ID
* @return 结果
*/
AjaxResult multipleFilePartUpload(String folderName, List<MultipartFile> fileParts, String fileName, Integer partNum, Long recordId);
}
3、Service实现
package com.company.project.service.impl;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.company.common.core.baseweb.domain.AjaxResult;
import com.company.common.core.constant.Constants;
import com.company.common.core.utils.file.FileUtils;
import com.company.common.framework.minio.MinioUtil;
import com.company.project.domain.TaskRecord;
import com.company.project.service.IFileUploadMinioService;
import com.company.project.service.ITaskRecordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.stream.Collectors;
/**
* 大文件切片上传(断点续传)
*
* @author kally
* @date 2023-08-10
*/
@Slf4j
@Service
public class FileUploadMinioServiceImpl implements IFileUploadMinioService {
@Autowired
private MinioUtil minioUtil;
@Autowired
private ITaskRecordService recordService;
@Override
public AjaxResult singleFilePartUpload(String folderName, MultipartFile filePart, String fileName, Integer partNum, Long recordId) {
// 实际情况下,这些路径都应该是服务器上面存储文件的路径
// 文件存放路径
String filePath = Constants.WINDPVELEC_TEMP_FILE_PATH + "uploadFile/";
// 临时文件存放路径
String tempPath = filePath + "temp/" + folderName;
File dir = new File(tempPath);
if (!dir.exists()) {
dir.mkdirs();
}
// 生成一个临时文件名
try {
// 将分片存储到临时文件夹中
File file = new File(tempPath, filePart.getOriginalFilename());
if (file.exists()) {
log.info(filePart.getOriginalFilename() + "文件已存在!");
} else {
// 断点续传
uploadFileResume(filePart, file);
}
File tempDir = new File(tempPath);
File[] tempFiles = tempDir.listFiles();
Map<String, Object> isMergePart = new HashMap<>();
one:
if (partNum.equals(Objects.requireNonNull(tempFiles).length)) {
// 需要校验一下,表示已有异步程序正在合并了;如果是分布式这个校验可以加入redis的分布式锁来完成
if (isMergePart.get(folderName) != null) {
break one;
}
isMergePart.put(folderName, tempFiles.length);
log.info("所有分片上传完成,预计总分片:" + partNum + "; 实际总分片:" + tempFiles.length);
FileOutputStream fileOutputStream = new FileOutputStream(filePath + fileName);
// 这里如果分片很多的情况下,可以采用多线程来执行
for (int i = 1; i <= partNum; i++) {
// 读取分片数据,进行分片合并
FileInputStream fileInputStream = new FileInputStream(tempPath + "/" + fileName + "_" + i + ".part");
// 8MB
byte[] buf = new byte[1024 * 10];
int length;
// 读取fis文件输入字节流里面的数据
while ((length = fileInputStream.read(buf)) != -1) {
// 通过fos文件输出字节流写出去
fileOutputStream.write(buf, 0, length);
}
fileInputStream.close();
}
fileOutputStream.flush();
fileOutputStream.close();
// 删除临时文件夹里面的分片文件 如果使用流操作且没有关闭输入流,可能导致删除失败
for (int i = 1; i <= partNum; i++) {
boolean delete = new File(tempPath + "/" + fileName + "_" + i + ".part").delete();
File fileDelete = new File(tempPath + "/" + fileName + "_" + i + ".part");
log.info(i + " ; 是否删除:" + delete + " ; 是否还存在:" + fileDelete.exists());
}
// 在删除对应的临时文件夹
if (Objects.requireNonNull(tempDir.listFiles()).length == 0) {
boolean delete = tempDir.delete();
log.info("文件夹 " + tempPath + "是否删除:" + delete);
}
isMergePart.remove(folderName);
// 返回文件上传结果:成功或失败
String imgUrl = minioUtil.uploadObjectAndObjectUrl(fileName, filePath + fileName);
log.info("分片上传=" + imgUrl);
FileUtils.deleteFile(filePath + fileName);
updateRecord(recordId, imgUrl);
return AjaxResult.success();
}
} catch (Exception e) {
log.error("单文件分片上传失败!", e);
return AjaxResult.error("单文件分片上传失败");
}
// 通过返回成功的分片值,来验证分片是否有丢失
// AjaxResult.success(filePart.getOriginalFilename());
return AjaxResult.success();
}
@Override
public AjaxResult multipleFilePartUpload(String folderName, List<MultipartFile> fileParts, String fileName, Integer partNum, Long recordId) {
// 文件存放路径(服务器的存储文件的路径)
String filePath = Constants.WINDPVELEC_TEMP_FILE_PATH + "uploadFile/";
// 临时文件存放路径
String tempPath = filePath + "temp/" + folderName;
File dir = new File(tempPath);
if (!dir.exists()) {
dir.mkdirs();
}
Map<String, Object> isMergePart = new HashMap<>();
File tempDir = new File(tempPath);
/** 创建线程池 **/
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("bigFilePart-pool-%d").build();
ExecutorService executorService = Executors.newFixedThreadPool(10, namedThreadFactory);
/** 大文件切片上传异步处理 **/
List<CompletableFuture<Void>> taskAsync = fileParts.stream()
.map(filePart -> CompletableFuture.runAsync(() -> {
// 将分片存储到临时文件夹中
File file = new File(tempDir, filePart.getOriginalFilename());
if (file.exists()) {
log.info(filePart.getOriginalFilename() + "文件已存在!");
} else {
// // 方式1:将文件内容从一个位置传到另一个位置
// try {
// filePart.transferTo(new File(tempPath + "/" + filePart.getOriginalFilename()));
// log.info("文件不存在,上传文件="+tempPath + "/" + filePart.getOriginalFilename());
// } catch (IOException e) {
// e.printStackTrace();
// }
// 方式3:断点续传
uploadFileResume(filePart, file);
}
}, executorService))
.collect(Collectors.toList());
/** 等待所有任务执行完成 **/
CompletableFuture.allOf(taskAsync.toArray(new CompletableFuture[0])).join();
/** 关闭线程池 **/
executorService.shutdown();
try {
File[] tempFiles = tempDir.listFiles();
one:
if (partNum.equals(Objects.requireNonNull(tempFiles).length)) {
// 需要校验一下,表示已有异步程序正在合并了;如果是分布式这个校验可以加入redis的分布式锁来完成
if (isMergePart.get(folderName) != null) {
break one;
}
isMergePart.put(folderName, tempFiles.length);
log.info("所有分片上传完成,预计总分片:" + partNum + "; 实际总分片:" + tempFiles.length);
FileOutputStream fileOutputStream = new FileOutputStream(filePath + fileName);
// 这里如果分片很多的情况下,可以采用多线程来执行
for (int i = 1; i <= partNum; i++) {
// 读取分片数据,进行分片合并
FileInputStream fileInputStream = new FileInputStream(tempPath + "/" + fileName + "_" + i + ".part");
// 8MB
byte[] buf = new byte[1024 * 8];
int length;
// 读取fis文件输入字节流里面的数据
while ((length = fileInputStream.read(buf)) != -1) {
// 通过fos文件输出字节流写出去
fileOutputStream.write(buf, 0, length);
}
fileInputStream.close();
}
fileOutputStream.flush();
fileOutputStream.close();
// 删除临时文件夹里面的分片文件 如果使用流操作且没有关闭输入流,可能导致删除失败
for (int i = 1; i <= partNum; i++) {
boolean delete = new File(tempPath + "/" + fileName + "_" + i + ".part").delete();
File file = new File(tempPath + "/" + fileName + "_" + i + ".part");
log.info(i + " ; 是否删除:" + delete + " ; 是否还存在:" + file.exists());
}
// 在删除对应的临时文件夹
if (Objects.requireNonNull(tempDir.listFiles()).length == 0) {
boolean delete = tempDir.delete();
log.info("文件夹 " + tempPath + "是否删除:" + delete);
}
isMergePart.remove(folderName);
// 返回文件上传结果:成功或失败
String imgUrl = minioUtil.uploadObjectAndObjectUrl(fileName, filePath + fileName);
log.info("总体上传=" + imgUrl);
FileUtils.deleteFile(filePath + fileName);
updateRecord(recordId, imgUrl);
return AjaxResult.success();
}
} catch (Exception e) {
log.error("单文件分片上传失败!", e);
return AjaxResult.error("单文件分片上传失败");
}
return AjaxResult.success();
}
/**
* 步骤1:将一个大文件切割成指定大小的小文件
* 备注:对已知文件进行切割操作 –> 得到多个碎片文件
*
* @param src 原大文件
* @param endsrc 切割后存放的小文件的路径
* @param num 切割规定的内存大小
*/
private static void cutFile(String src, String endsrc, int num) {
FileInputStream fis = null;
File file;
try {
fis = new FileInputStream(src);
file = new File(src);
//创建规定大小的byte数组
byte[] b = new byte[num];
int len = 0;
//name为以后的小文件命名做准备
int index = 0;
//遍历将大文件读入byte数组中,当byte数组读满后写入对应的小文件中
while ((len = fis.read(b)) != -1) {
//分别找到原大文件的文件名和文件类型,为下面的小文件命名做准备
String name2 = file.getName();
int lastIndexOf = name2.lastIndexOf(".");
String substring = name2.substring(0, lastIndexOf);
// String substring2 = name2.substring(lastIndexOf);
String fileSuffix = ".part";
//FileOutputStream fos = new FileOutputStream(endsrc + "\\\\" + substring + "_" + name + substring2);
FileOutputStream fos = new FileOutputStream(endsrc + "\\\\" + name2 + "_" + index + fileSuffix);
//将byte数组写入对应的小文件中
fos.write(b, 0, len);
//结束资源
fos.close();
index++;
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (fis != null) {
//结束资源
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 上传文件(考虑断点续传)--使用
*
* @param filePart 分片文件(源文件)
* @param file 目标文件
*/
private void uploadFileResume(MultipartFile filePart, File file) {
long uploadedBytes = 0;
if (!file.exists()) {
// 获取源文件大小
long fileSize = filePart.getSize();
// 随机访问文件
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw")) {
// 设置文件指针位置
randomAccessFile.seek(uploadedBytes);
// 文件输入流
try (FileInputStream fileInputStream = new FileInputStream(FileUtils.multipartFileToFile(filePart))) {
//文件流大小
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = fileInputStream.read(buffer)) != -1) {
// 写入到目标文件
randomAccessFile.write(buffer, 0, bytesRead);
uploadedBytes += bytesRead;
}
if (fileSize == uploadedBytes) {
log.info("分片文件上传完成," + filePart.getOriginalFilename());
}
} catch (IOException ioException) {
ioException.printStackTrace();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
/**
* 根据作业ID更新历史视频地址
*
* @param recordId 作业ID
* @param imgUrl 图片地址
*/
private void updateRecord(Long recordId, String imgUrl) {
recordService.updateRecord(TaskRecord.builder().recordId(recordId).videoUrl(imgUrl).build());
}
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
// 步骤1:切片处理文件
//调用cutFile()函数 传人参数分别为 (原大文件,切割后存放的小文件的路径,切割规定的内存大小)
//cutFile("D:\\java\\cut\\ForrestGump.avi", "D:\\java\\cuts", 1024 * 1024 * 20);
//cutFile("G:\\Users\\lenovo\\Desktop\\tangdou.mp4", "E:\\java\\IdeaProjects\\git-new3s\\datangchaoyang\\WindPvElecIntg\\file\\temp", 1024 * 1024 * 1);
//cutFile("G:\\Users\\lenovo\\Desktop\\DJI_20220504132406_0012_Z.JPG", "E:\\java\\file\\temp", 1024 * 1024 * 2);
cutFile("G:\\Users\\lenovo\\Desktop\\go3-u5-3.mp4", "E:\\java\\Idea\\file\\temp", 1024 * 1024 * 5);
}
}