一、前提说明
此文章主要讲述后端服务代码和前后端实现思路部分,不涉及前端代码。
二、应用场景
上传视频等大文件的时候,调用服务器的上传接口,可能出现因为文件过大,连接时间超时导致的上传失败,如果文件太大了,可能出现上传一半网络异常,从而再次上传需要重新开始上传。为了解决这种场景问题,手写一个大文件上传实现切片上传,断点上传和文件秒传的功能。
三、概念
切片
:切片上传是一种将大文件分割成多个小文件的方式,此时小文件就是切片。
断点上传
:在文件分块的基础上,将每个小文件采用单独的线程进行上传\下载,如果碰到网络故障,可以从已经上传\下载的部分开始继续上传\下载未完成的部分,而没有必要从头开始上传\下载。
秒传
:当文件上传时,文件资源标识存在时,文件不再重新上传,而直接返回文件URL。
四、思路
4.1 前端思路
- 获取大文件信息包含(文件名称,文件大小,
格式类型
) - 大文件分块可以利用强大的js库或者现成的组件进行分块处理。需要确定分块
【chunk】
的大小和分块的总数量【chunkChecksum】
,然后为每一个分块指定一个索引值【chunkIndex】
。 - 为了实现秒传的功能,需要将文件的文件名称,文件大小,格式类型拼接然后进行MD5加密作为文件的唯一标识
【fileId】
。 - 循环切块,将上面标红的信息作为参数传给接口,此时将接口返回的date,作为下一个需要传的切片索引,再次走接口,这是为了实现断点上传。
- 直到接口返回date是url地址为止,此时上传完成。
- 文件的暂停和继续上传由前端自行研究。
4.2 后端思路
redis使用redisTemplate.opsForList()的方式存贮切片,然后最后循环缓存合并,最好使用redisTemplate.opsForList().rightPush(key,value)存,并且设置过期时间防止上传一部分的放弃上传造成内存占用。redis使用自行百度。
- 获取到前端传的信息,利用redis暂存切片。
- 根据当前大文件的
fileId
,然后去数据库查询,如果存在,则直接返回当前大文件的地址,实现秒传功能 - 判断当前切片索引是否上传过,上传过则在判断如果
已存在切片数量=总数量
则直接合成切片,否则直接返回下一个需要上传的切片索引值, - 当前切片没有上传过,则需要把当前文件暂时保存到redis中,并且返回下一个需要上传的切片索引值
- 判断是否切片上传完成,上传完成则合并切片形成大文件,否则直接进行下一个切片上传
五、接口实现过程
/**
* 上传大文件
*
* @param chunk 切片文件
* @param chunkIndex 切片索引
* @param chunkChecksum 切片总数
* @param fileFormat 文件格式
* @param fileId 大文件标志(最好是让前端把文件的(名称+大小+类型)进行MD5加密)
* @return
* @throws Exception
*/
@Inside(value = false)
@PostMapping("/uploadBigFile")
public R uploadFile(@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("chunkChecksum") Integer chunkChecksum,
@RequestParam("fileFormat") String fileFormat,
@RequestParam("fileId") String fileId) throws Exception {
log.info("切片索引" + chunkIndex);
log.info("切片总数" + chunkChecksum);
String key = CommonConstants.FILE_KEY + fileId;
//STEP 获取当前大文件的id,然后去数据库查询,如果存在,则直接返回当前大文件的地址,实现秒传功能
Material material = materialService.getByFileId(fileId);
if (material != null) {
return R.ok(material.getUrl(), "上传成功,实现秒传");
}
//STEP 判断当前切片索引是否上传过,上传过则在判断如果已存在切片数量=总数量则直接合成切片,否则直接返回下一个需要上传的切片索引值,
Object chunkIndexRedis = redisTemplate.opsForList().index(key, chunkIndex);
if (chunkIndexRedis != null) {
Long chunkIndexRedisMax = redisTemplate.opsForList().size(key);
log.info("已经上传的切片数量" + chunkIndexRedisMax);
int chunkIndexRedisIntMax = chunkIndexRedisMax.intValue();
if (chunkIndexRedisIntMax == chunkChecksum) {
String merge = merge(key, fileFormat);
redisTemplate.delete(key);
return R.ok(merge, "上传完成,实现切片合并异常");
}
return R.ok(chunkIndexRedisMax + 1, "上传成功,实现断点功能");
}
//STEP 当前切片没有上传过,则需要把当前文件暂时保存到redis中,并且返回下一个需要上传的切片索引值
//分片文件大小
byte[] chunkBytes = chunk.getBytes();
redisTemplate.opsForList().rightPush(key, chunkBytes);
redisTemplate.expire(key, CommonConstants.FILE_KEY_OUT_TIME, TimeUnit.MINUTES);
//STEP 判断是否切片上传完成,上传完成则合并切片形成大文件,否则直接进行下一个切片上传
if (chunkIndex == chunkChecksum) {
String merge = merge(key, fileFormat);
redisTemplate.delete(key);
return R.ok(merge, "上传完成,实现全部切片上传");
}
return R.ok(chunkIndex + 1, "上传成功,实现当前切片上传");
}
/**
* 合并切片,把切片写入到文件夹中
*
* @param key redis中的key
* @param fileFormat 文件格式
* @return
* @throws FileNotFoundException
*/
public String merge(String key, String fileFormat) throws FileNotFoundException {
log.info("合并切片");
//文件名称随机生成(文件名+.+后缀)
String fileName = IdUtil.getSnowflake(0, 0).nextId() + "." + fileFormat;
//文件地址
String path = UpmsConstants.TOMCAT_PATH + "/video/";
//判断文件是否存在,不存在创建文件
File newFile = new File(path);
//如果文件夹不存在
if (!newFile.exists()) {
//创建文件夹
newFile.mkdir();
}
FileOutputStream outputStream = new FileOutputStream(path + fileName, true);//文件追加写入
FileInputStream fileInputStream = null;//分片文件
try {
List<byte[]> chunkList = redisTemplate.opsForList().range(key, 0, -1);
for (byte[] bytes : chunkList) {
outputStream.write(bytes);
}
} catch (IOException e) {
log.error("分片合并异常", e);
} finally {
try {
if (fileInputStream != null) {
fileInputStream.close();
}
outputStream.close();
log.info("IO流关闭");
System.gc();
} catch (Exception e) {
log.error("IO流关闭", e);
}
}
log.info("合并分片结束");
return UpmsConstants.TOMCAT_Url + "video/" + fileName;
}
六、日志结果
此时日志打印如下:
文件存储成功并且可以成功播放。