一、背景:
后台系统需要上传大文件、大视频等数据,耗时过长,接口等待超时,故需优化通过前端多线程+分片方式进行文件上传,显著提升上传速度。
二、流程:
前端逻辑:
前端使用分片技术,将大文或大视频件进行分片,可使用webupload,将视频进行切片,多线程上传后台服务器。同时计算hash值用于文件完整性校验。同时对每个分片视频或文件进行顺序记录,后面单个分片数据异常可以针对单个文件进行重新上传。
后端逻辑:
准备接口1:前端生成大文件的MD5,调用接口把MD5传递来,后端会用这个MD5去创建一个文件夹,一个视频就对应一个文件夹。
接口2:前端带MD5+视频分片信息去调用此接口,后端会根据MD5找到对应的文件夹,判断这个分片是否已经上传过了。同时也可以那这个分片号做异常返回,让前端重传。
接口3:前端根据接口2的调用进行判断,如果该分片已经上传则跳过,如果没有上传则进行该分片上传,传递MD5+视频分片信息,后端根据MD5找到文件夹,将视频分片存储起来
接口4:当所有视频通过多线程传完成,调用接口4,传递MD5+表单信息,后端通过流合并,把所有的分片信息整合成一个视频,此时上传完毕,视频已存储到服务器本地。后端还可以异步通过调用三方文件存储(OSS)进行上传对象服务器,生成访问地址存储数据库,修改对应数据状态位,同时可删除临时服务端文件。
三、后端接口及业务
@RequestMapping("/common/core/bigFile")
@RestController
public class BigFileController {
/**
* 创建文件夹
*/
@PostMapping("/createFolder")
public JsonResult createFolder(@RequestParam("fileMd5") String fileMd5, // 文件md5
@RequestParam("fileName") String fileName, // 文件名
@RequestParam("fileExt") String fileExt //文件扩展名
) {
return bigFileService.createFolder(fileMd5,fileName,fileSize,mimetype,fileExt);
}
/**
* 检查分片接口
*/
@PostMapping("/checkChunk")
public Result checkChunk(
@RequestParam("fileMd5") String fileMd5, //文件md5
@RequestParam("chunk") Integer chunk // 当前分块下标
){
return bigFileService.checkChunk(fileMd5,chunk,chunkSize);
}
/**
* 上传分片接口
*/
@PostMapping("/uploadChunk")
public Result uploadChunk(
@RequestParam("file") MultipartFile file, //分块后的文件
@RequestParam("fileMd5") String fileMd5, // 文件md5
@RequestParam("chunk") Integer chunk // 分片标识
){
return bigFileService.uploadChunk(file,fileMd5,chunk);
}
/**
* 合并分片接口
*/
@PostMapping("/mergeChunks")
public Result mergeChunks(
@RequestParam("deptId") Long deptId, // 部门id
@RequestParam("remark") String remark, // 备注
@RequestParam("fileMd5") String fileMd5,// 文件md5
@RequestParam("fileName") String fileName, // 原文件名
@RequestParam("fileSize") Long fileSize, // 文件总大小
@RequestParam("fileType") String fileType, // 文件类型
@RequestParam("fileExt") String fileExt // 扩展名
){
return bigFileService.mergeChunks(deptId,remark,fileMd5,fileName,fileSize,mimetype,fileExt);
}
}
@Service
@Slf4j
public class BigFileService {
@Autowired
private BigFileMapper bigFileMapper;
// 临时文件目录
private String uploadPath = "/data/bigFile/temp/";
/**
* 创建文件夹
*/
public Result createFolder(String fileMd5, String fileName, String fileExt) {
//检查文件是否存在
String filePath = getFilePath(fileMd5, fileExt);
File file = new File(filePath);
if (file.exists()) {
log.info("文件夹已存在 {} ", fileName);
return Result.error("上传文件已存在");
}
boolean fileFold = createFileFold(fileMd5);
if (!fileFold) {
//上传文件目录创建失败
log.info("上传文件夹创建失败 {} ,文件夹存在", fileName);
return Result.error("上传文件夹失败");
}
return Result.success();
}
/**
* 检查分片
*/
public Result cheCkchunk(String fileMd5, Integer chunk) {
//获取块文件文件夹路径
String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
// 块文件的下标 1 2 3 排序使用
File chunkFile = new File(chunkfileFolderPath + chunk);
if (!chunkFile.exists()) {
return Result.error();
}
return Result.success();
}
/**
* 上传分片
*/
public Result uploadChunk(MultipartFile file, String fileMd5, Integer chunk) {
//块文件存放完整路径
File chunkfile = new File(getChunkFileFolderPath(fileMd5) + chunk);
//上传的块文件
InputStream inputStream = null;
FileOutputStream outputStream = null;
try {
inputStream = file.getInputStream();
outputStream = new FileOutputStream(chunkfile);
IOUtils.copy(inputStream, outputStream);
} catch (Exception e) {
e.printStackTrace();
return Result.error("文件上传失败!");
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return Result.success();
}
/**
* 合并分片
*/
public Result mergeChunks(String deptId, String remark, String fileMd5, String fileName, Long fileSize, String fileType, String fileExt) {
//获取块文件的路径
String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
//创建文件目录
File chunkfileFolder = new File(chunkfileFolderPath);
//目录是否存在, 不存在就创建目录
if (!chunkfileFolder.exists()) {
chunkfileFolder.mkdirs();
}
//合并文件,创建新的文件对象
File mergeFile = new File(getFilePath(fileMd5, fileExt));
// 合并文件存在先删除再创建
if (mergeFile.exists()) {
mergeFile.delete();
}
boolean newFile = false;
try {
//创建文件
newFile = mergeFile.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
if (!newFile) {
//创建失败
return Result.error("创建文件失败!");
}
//获取块文件,此列表是已经排好序的列表
List<File> chunkFiles = getChunkFiles(chunkfileFolder);
//合并文件
mergeFile = mergeFile(mergeFile, chunkFiles);
if (mergeFile == null) {
return Result.error("合并文件失败!");
}
//校验文件
boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
if (!checkResult) {
return Result.error("文件校验失败!");
}
//将文件信息保存到数据库
BigFile bigFile = new BigFile();
//MD5作为文件唯一ID
bigFile.setFileId(fileMd5);
//文件名
bigFile.setFileName(fileMd5 + "." + fileExt);
//源文件名
bigFile.setFileOriginalName(fileName);
//文件路径保存相对路径
bigFile.setFilePath(getFileFolderRelativePath(fileMd5, fileExt));
bigFile.setFileSize(fileSize);
bigFile.setUploadTime(new Date());
bigFile.setFileType(fileType);
bigFile.setFileType(fileExt);
bigFile.setDeptId(deptId);
bigFile.setRemark(remark);
// 更新状态
bigFile.setFileStatus(1);
bigFileMapper.insert(mediaFile);
// 后台上传oss完毕再更新云端地址,更新状态
return Result.success("视频上传成功");
}
/*
*根据文件md5得到文件路径
*/
private String getFilePath(String fileMd5, String fileExt) {
String filePath = uploadPath + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + "." + fileExt;
return filePath;
}
//得到文件目录相对路径,路径中去掉根目录
private String getFileFolderRelativePath(String fileMd5, String fileExt) {
String filePath = fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
return filePath;
}
//得到文件所在目录
private String getFileFolderPath(String fileMd5) {
String fileFolderPath = uploadPath + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
return fileFolderPath;
}
//创建文件目录
private boolean createFileFold(String fileMd5) {
//创建上传文件目录
String fileFolderPath = getFileFolderPath(fileMd5);
File fileFolder = new File(fileFolderPath);
if (!fileFolder.exists()) {
//创建文件夹
boolean mkdirs = fileFolder.mkdirs();
log.info("创建文件目录 {} ,结果 {}", fileFolder.getPath(), mkdirs);
return mkdirs;
}
return true;
}
//得到块文件所在目录
private String getChunkFileFolderPath(String fileMd5) {
String fileChunkFolderPath = getFileFolderPath(fileMd5) + "/" + "chunks" + "/";
return fileChunkFolderPath;
}
/**
* 创建块文件目录
*/
private boolean createChunkFileFolder(String fileMd5) {
//创建上传文件目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
File chunkFileFolder = new File(chunkFileFolderPath);
if (!chunkFileFolder.exists()) {
//创建文件夹
boolean mkdirs = chunkFileFolder.mkdirs();
return mkdirs;
}
return true;
}
/**
* 校验文件完整性
* @param mergeFile
* @param md5
* @return
*/
private boolean checkFileMd5(File mergeFile, String md5) {
if (mergeFile == null || StringUtils.isEmpty(md5)) {
return false;
}
//进行md5校验
FileInputStream mergeFileInputstream = null;
try {
mergeFileInputstream = new FileInputStream(mergeFile);
//得到文件的md5
String mergeFileMd5 = DigestUtils.md5Hex(mergeFileInputstream);
//比较md5
if (md5.equalsIgnoreCase(mergeFileMd5)) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
mergeFileInputstream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
/**
* 获取所有分片文件
* @param chunkfileFolder
* @return
*/
private List<File> getChunkFiles(File chunkfileFolder) {
//获取路径下的所有块文件
File[] chunkFiles = chunkfileFolder.listFiles();
//将文件数组转成list,并排序
List<File> chunkFileList = new ArrayList<File>();
chunkFileList.addAll(Arrays.asList(chunkFiles));
//排序
Collections.sort(chunkFileList, (o1, o2) -> {
if (Integer.parseInt(o1.getName()) > Integer.parseInt(o2.getName())) {
return 1;
}
return -1;
});
return chunkFileList;
}
/**
* 合并文件 流写入
* @param mergeFile
* @param chunkFiles
* @return
*/
private File mergeFile(File mergeFile, List<File> chunkFiles) {
try {
//创建写文件对象
RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
//遍历分块文件开始合并
// 读取文件缓冲区
byte[] b = new byte[1024];
for (File chunkFile : chunkFiles) {
RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "r");
int len = -1;
//读取分块文件
while ((len = raf_read.read(b)) != -1) {
//向合并文件中写数据
raf_write.write(b, 0, len);
}
raf_read.close();
}
raf_write.close();
} catch (Exception e) {
e.printStackTrace();
return null;
}
return mergeFile;
}
}
@Data
public class BigFile {
private Long id;
/**
* 文件的ID
*/
private String fileId;
/**
* 文件名
*/
private String fileName;
/**
* 源文件名
*/
private String fileOriginalName;
/**
* oss地址
*/
private String fileUrl;
/**
* 临时本地存储目录
*/
private String filePath;
/**
* 文件类型
*/
private String fileType;
/**
* 1待上传 2 上传成功 3上传失败
*/
private Integer fileStatus;
/**
* 文件总大小
*/
private Long fileSize;
/**
* 上传时间
*/
private Date uploadTime;
/**
* 部门id
*/
private Long deptId;
/**
* 备注
*/
private String remark;
}
数据库
CREATE TABLE `big_file` (
`id` bigint NOT NULL AUTO_INCREMENT,
`file_id` varchar(255) DEFAULT NULL COMMENT '文件的ID',
`file_name` varchar(255) DEFAULT NULL COMMENT '文件名',
`file_original_name` varchar(255) DEFAULT NULL COMMENT '原文件名',
`file_url` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT 'oss地址',
`file_path` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '临时本地存储目录',
`file_type` varchar(255) DEFAULT NULL COMMENT '文件类型',
`file_status` tinyint DEFAULT NULL COMMENT '1待上传 2 上传成功 3上传失败',
`file_size` bigint DEFAULT NULL COMMENT '文件总大小',
`upload_time` datetime DEFAULT NULL COMMENT '上传时间',
`dept_id` bigint DEFAULT NULL COMMENT '部门id',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3;
四、文件上传服务器本地完毕
上传到本地,可以推送到三方服务器进行存储。