一开始没做过断点续传,刚好公司有这个需求,网上找大神找了资料,特此记录下
转载于 https://cloud.tencent.com/developer/article/1541199,感谢大神
前言
上传是一个老生常谈的话题了,在文件相对比较小的情况下,可以直接把文件转化为字节流上传到服务器,但在文件比较大的情况下,用普通的方式进行上传,这可不是一个好的办法,毕竟很少有人会忍受,当文件上传到一半中断后,继续上传却只能重头开始上传,这种让人不爽的体验。那有没有比较好的上传体验呢,答案有的,就是下边要介绍的几种上传方式
一.正文
分片上传
1、什么分片上传
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件
2、分片上传适用场景
大文件上传
网络环境环境不好,存在需要重传风险的场景
3、上传的具体流程
因为这个上传流程和断点续传类似,就在下边介绍断点续传中介绍
断点续传
1、什么是断点续传
断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。本文的断点续传主要是针对断点上传场景
2、应用场景
断点续传可以看成是分片上传的一个衍生,因此可以使用分片上传的场景,都可以使用断点续传
3、实现断点续传的核心逻辑
在分片上传的过程中,如果因为系统崩溃或者网络中断等异常因素导致上传中断,这时候客户端需要记录上传的进度。在之后支持再次上传时,可以继续从上次上传中断的地方进行继续上传。
为了避免客户端在上传之后的进度数据被删除而导致重新开始从头上传的问题,服务端也可以提供相应的接口便于客户端对已经上传的分片数据进行查询,从而使客户端知道已经上传的分片数据,从而从下一个分片数据开始继续上传。
4、实现流程步骤
a、方案一,常规步骤
将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
初始化一个分片上传任务,返回本次分片上传唯一标识;
按照一定的策略(串行或并行)发送各个分片数据块;
发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。
b、方案二、本文实现的步骤
前端(客户端)需要根据固定大小对文件进行分片,请求后端(服务端)时要带上分片序号和大小
服务端创建conf文件用来记录分块位置,conf文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127(这步是实现断点续传和秒传的核心步骤)
服务器按照请求数据中给的分片序号和每片分块大小(分片大小是固定且一样的)算出开始位置,与读取到的文件片段数据,写入文件
分片上传/断点上传代码实现
该实现主要是参照博主Fourwen的博文–>Java实现浏览器端大文件分片上传进行实现,博文内容具体可以查看如下链接
https://blog.csdn.net/u014150463/article/details/74044467
本文的上传代码核心内容基本上都取自这篇文章,只是在文章的基础上做了一些修复,比如在大文件上传中,如果原文造抄就会报如下的错误
请求的操作无法在使用用户映射区域打开的文件上执行
出现该bug的原因是原文的博主,操作后没有进行相应的流关闭
1、本文使用分片上传的核心步骤
a、前端采用百度提供的webuploader的插件,进行分片。因本文主要介绍服务端代码实现,webuploader如何进行分片,具体实现可以查看如下链接
http://1t.click/aJQR
如果对webuploader感兴趣的朋友,可以查看官网,链接如下
http://fex.baidu.com/webuploader/getting-started.html
b、后端用两种方式实现文件写入,一种是用RandomAccessFile,如果对RandomAccessFile不熟悉的朋友,可以查看如下链接
https://blog.csdn.net/dimudan2015/article/details/81910690
另一种是使用MappedByteBuffer,对MappedByteBuffer不熟悉的朋友,可以查看如下链接进行了解
https://www.jianshu.com/p/f90866dcbffc
2、后端进行写入操作的核心代码
二、具体代码实现
1.控制器
/**
* 断点续传——判断当前文件上传至多少个分片了
* @param fileMd5 上传文件的md5值
* @return
*/
@AuthorizationFree
@PostMapping(value = "/checkFileMd5", produces = "application/json;charset=UTF-8")
public RestResult checkFileMd5( HttpServletRequest request,
@RequestParam("fileMd5") String fileMd5) throws IOException {
return fileService.checkFileMd5(fileMd5);
}
/**
* 断点续传——文件上传接口
*
* @param file
* @param token
* @return
* @throws FileNotFoundException
*/
@AuthorizationFree
@PostMapping(value = "/file", produces = "application/json;charset=UTF-8")
public RestResult recFile(
HttpServletRequest request,
@RequestParam("token") String token,
@RequestParam(value = "type", required = false) String type,
@RequestParam("totalChunks") String totalChunks,
@RequestParam("currentChunks") String currentChunks,
@RequestParam("fileName") String fileName,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("file") MultipartFile file
) throws FileNotFoundException {
String ip = HttpServletHelper.getRequestIp(request);
return fileService.uploadFile(file, token, type, ip,totalChunks,currentChunks,fileName,fileMd5);
}
2.工具类、以及model还有其他
public class InterceptorConstant {
* 缓存键值
*/
public static final Map<Class<?>, String> cacheKeyMap = new HashMap<>();
/**
* 保存文件所在路径的key,eg.FILE_MD5:1243jkalsjflkwaejklgjawe
*/
public static final String FILE_MD5_KEY = "FILE_MD5:";
/**
* 保存上传文件的状态
*/
public static final String FILE_UPLOAD_STATUS = "FILE_UPLOAD_STATUS";
}
public class MultipartFileParam {
// 用户id
private String uid;
//任务ID
private String id;
//总分片数量
private int totalChunks;
//当前为第几块分片
private int currentChunks;
//当前分片大小
private long size = 0L;
//文件名
private String fileName;
//分片对象
private MultipartFile file;
// MD5
private String fileMd5;
public String getUid() {
return uid;
}
public void setUid(String uid) {
this.uid = uid;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public int getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(int totalChunks) {
this.totalChunks = totalChunks;
}
public int getCurrentChunks() {
return currentChunks;
}
public void setCurrentChunks(int currentChunks) {
this.currentChunks = currentChunks;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public MultipartFile getFile() {
return file;
}
public void setFile(MultipartFile file) {
this.file = file;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
fileName = fileName;
}
public String getFileMd5() {
return fileMd5;
}
public void setFileMd5(String fileMd5) {
this.fileMd5 = fileMd5;
}
@Override
public String toString() {
return "MultipartFileParam{" +
"uid='" + uid + '\'' +
", id='" + id + '\'' +
", totalChunks=" + totalChunks +
", currentChunks=" + currentChunks +
", size=" + size +
", fileName='" + fileName + '\'' +
", file=" + file +
", fileMd5='" + fileMd5 + '\'' +
'}';
}
}
@Component
public class RedisOption {
/**
* 载入模板供静态方法调用
*/
@Autowired
private RedisTemplate<String, String> redisTemplateHelp;
private RedisTemplate<String, String> redisTemplate;
/**
* 获取redis变量中的指定map键是否有值,如果存在该map键则获取值,没有则返回null
*
* @param key 存入的变量值
* @param hashKey map的键
* @return
*/
public Object getMapKeyValue(Object key, Object hashKey) {
Object processingObj = redisTemplate.opsForHash().get(key.toString(), hashKey);
return processingObj;
}
/**
* 获取redis变量中的指定key的值
*
* @param key 存入的key
* @return
*/
public Object getKeyValue(Object key) {
return redisTemplate.opsForValue().get(key);
}
/**
* 设置插入普通值
*
* @param key
* @param values
* @return
*/
public boolean setValues(Object key, Object values) {
try {
ValueOperations<String, String> redis = redisTemplate.opsForValue();
redis.set(key.toString(), values.toString());
redisTemplate.expire(key.toString(), this.uploadAgingTime, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 设置插入redis map
*
*
* @param key
* @param hashKey
* @param values
* @return
*/
public boolean setMapValues(Object key, Object hashKey, Object values) {
try {
redisTemplate.opsForHash().put(key.toString(), hashKey.toString(), values.toString());
redisTemplate.expire(key.toString(), this.uploadAgingTime, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 判断redis 变量中是否有指定的map键。
*
* @param key
* @return
*/
public boolean hasKeys(Object key,Object hashKey) {
try {
boolean bln= redisTemplate.opsForHash().hasKey(key.toString(),hashKey);
return bln;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 判断redis 变量中是否有指定的key。
*
* @param key
* @return
*/
public boolean hasKey(Object key) {
try {
boolean bln= redisTemplate.hasKey(key.toString());
return bln;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
@Service
public class FileImpl implements FileService {
/**
* 文件断点续传至本地文件夹,然后再上传至文件服务器
* @param param
* @return
*/
public File uploadFileRandomAccessFile(MultipartFileParam param) throws IOException {
String tempDirPath = tempPath + param.getFileMd5();
String tempFileName = param.getFileName() + "_tmp";
RandomAccessFile accessTmpFile =null;
File tmpFile =null;
FileInputStream fis = null;
String md5 = "";
try {
File tmpDir = new File(tempDirPath);
tmpFile = new File(tempDirPath, tempFileName);
if (!tmpDir.exists()) {
tmpDir.mkdirs();
}
accessTmpFile = new RandomAccessFile(tmpFile, "rw");
long offset = CHUNK_SIZE * param.getCurrentChunks();
//定位到该分片的偏移量
accessTmpFile.seek(offset);
//写入该分片数据
accessTmpFile.write(param.getFile().getBytes());
boolean isOk = checkAndSetUploadProgress(param, tempDirPath);
if (isOk) {
//重命名文件,然后校验MD5文件是否一致
fis = new FileInputStream(tmpFile.getPath());
md5= DigestUtils.md5DigestAsHex(fis);
boolean flag = FileUtils.renameFile(tmpFile, param.getFileName());
if(StringUtil.isNotBlank(md5) && !md5.equals(param.getFileMd5())){
//不是同一文件抛出异常
throw new IOException();
}
log.info("upload complete !!" + flag + " name=" + param.getFileName());
}
} catch (FileNotFoundException e) {//出现网络或者其他问题的时候,此时记录读写文件的位置,持久化到redis
log.error("e = {}", e.getMessage());
} catch (IOException e) {//出现网络或者其他问题的时候,此时记录读写文件的位置,持久化到redis
log.error("e = {}", e.getMessage());
}finally {
if(fis!=null){
fis.close();
}
// 释放
accessTmpFile.close();
return tmpFile;
}
}
/**
* 组装MultipartFileParam
* @param file
* @param totalChunks
* @param currentChunks
* @param fileName
* @param fileMd5
* @return
*/
public MultipartFileParam getMultipartFileParams(MultipartFile file, String totalChunks, String currentChunks, String fileName, String fileMd5){
MultipartFileParam param=new MultipartFileParam();
param.setCurrentChunks(StringUtil.isBlank(currentChunks)?Integer.valueOf(currentChunks):0);
param.setTotalChunks(StringUtil.isBlank(totalChunks)?Integer.valueOf(totalChunks):0);
param.setFile(file);
param.setFileMd5(fileMd5);
param.setFileName(fileName);
return param;
}
/**
* 检查并修改文件上传进度
*
* @param param
* @param uploadDirPath
* @return
* @throws IOException
*/
private boolean checkAndSetUploadProgress(MultipartFileParam param, String uploadDirPath) throws IOException {
String fileName = param.getFileName();
File confFile = new File(uploadDirPath, fileName + ".conf");
RandomAccessFile accessConfFile = new RandomAccessFile(confFile, "rw");
//把该分段标记为 true 表示完成
log.info("set part " + param.getCurrentChunks() + " complete");
accessConfFile.setLength(param.getTotalChunks());
accessConfFile.seek(param.getCurrentChunks());
accessConfFile.write(Byte.MAX_VALUE);
//completeList 检查是否全部完成,如果数组里是否全部都是(全部分片都成功上传)
byte[] completeList = org.apache.commons.io.FileUtils.readFileToByteArray(confFile);
byte isComplete = Byte.MAX_VALUE;
for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {
//与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUE
isComplete = (byte) (isComplete & completeList[i]);
log.info("check part " + i + " complete?:" + completeList[i]);
}
accessConfFile.close();
if (isComplete == Byte.MAX_VALUE) {
redisOption.setMapValues(InterceptorConstant.FILE_UPLOAD_STATUS, param.getFileMd5(), "true");
redisOption.setValues(InterceptorConstant.FILE_MD5_KEY + param.getFileMd5(), uploadDirPath + "/" + fileName);
confFile.delete();//上传成功删除.cnf文件
return true;
} else {
if (!redisOption.hasKeys(InterceptorConstant.FILE_UPLOAD_STATUS, param.getFileMd5())) {
redisOption.setMapValues(InterceptorConstant.FILE_UPLOAD_STATUS, param.getFileMd5(), "false");
}
if (!redisOption.hasKey(InterceptorConstant.FILE_MD5_KEY + param.getFileMd5())) {
redisOption.setValues(InterceptorConstant.FILE_MD5_KEY + param.getFileMd5(), uploadDirPath + "/" + fileName + ".conf");
}
return false;
}
}
}
package com.medcaptain.utils;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.commons.CommonsMultipartFile;
import java.io.*;
import java.util.List;
/**
* @author tangjunhua
* @title FileUtils
* @Description 文件处理工具类
* @Date 2020/6/28 9:57
* @Copyright MEDCAPTIAN
*/
public class FileUtils {
private static final Logger log = LoggerFactory.getLogger(FileUtils.class);
// 删除指定文件夹下所有文件
public static void deleteFiles(String localFiles) { //参数是根文件夹
File rootFile=new File(localFiles);
if (rootFile.listFiles().length == 0) {// 如果用户给定的是空文件夹就退出方法
return;//退出
} else {
File[] files = rootFile.listFiles();// 将非空文件夹转换成File数组
for (File file : files) {//使用foreach语句遍历文件数组
if (file.isFile()) {//判断是否为文件
file.delete();// 删除指定文件夹下的所有文件
} else {
if (file.listFiles().length == 0) {//file类型是文件夹且文件夹为空
file.delete();// 删除指定文件夹下的所有空文件夹
} else {
deleteDirectories(file);// 删除指定文件夹下的所有非空文件夹(包括file)
}
}
}
}
}
// 删除文件夹及文件夹下所有文件
public static void deleteDirectories(File rootFile) {
if (rootFile.isFile()) {//第一次肯定不是文件类型,因为deleteFiles方法中已经判断过了
rootFile.delete();// 如果给定的File对象是文件就直接删除
} else {// 如果是一个文件夹就将其转换成File数组
File[] files = rootFile.listFiles();// 将非空文件夹转换成File数组
for (File file : files) {//使用foreach语句遍历文件数组
deleteDirectories(file);// 如果不是空文件夹则就迭代deleteDictionary()方法
}
rootFile.delete();// 如果是空文件夹就直接删除
}
}
// 获得指定目录下的所有文件的路径
public static List<String> getFilePath(List<String> list, File rootFile) {//返回值的就是传入的List<String> list类型,用于输出被删除的文件
File[] files = rootFile.listFiles();// 将非空文件夹转换成File数组
for (File file : files) {//使用foreach语句遍历文件数组
if (file.isDirectory()) {//判断是否为文件夹
getFilePath(list, file);//如果是文件夹则就迭代getFilePath()方法
} else {
//添加file的绝对路径添加到list中,在 UNIX 系统上,此字段的值为 '/';在 Microsoft Windows 系统上,它为 '\'
list.add(file.getAbsolutePath().replace("\\", File.separator));
}
}
//返回所有文件路径,我利用自动生成的文件夹程序,然后再删除发现文本域没输出,原来获得的只是文件路径
return list;//文件的路径是文件!文件!文件!
}
//file转 MultipartFile
public static MultipartFile getMulFileByPath(String picPath) {
FileItem fileItem = createFileItem(picPath);
MultipartFile mfile = new CommonsMultipartFile(fileItem);
return mfile;
}
private static FileItem createFileItem(String filePath)
{
FileItemFactory factory = new DiskFileItemFactory(16, null);
String textFieldName = "textField";
int num = filePath.lastIndexOf(".");
String extFile = filePath.substring(num);
FileItem item = factory.createItem(textFieldName, "text/plain", true,
"MyFileName" + extFile);
File newfile = new File(filePath);
int bytesRead = 0;
byte[] buffer = new byte[8192];
try
{
FileInputStream fis = null;
try {
fis = new FileInputStream(newfile);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
OutputStream os = null;
try {
os = item.getOutputStream();
} catch (IOException e) {
e.printStackTrace();
}
while ((bytesRead = fis.read(buffer, 0, 8192))
!= -1)
{
os.write(buffer, 0, bytesRead);
}
os.close();
fis.close();
}
catch (IOException e)
{
e.printStackTrace();
}
return item;
}
/**
* 文件拷贝
* @param sourceFile
* @param targetFile
* @return
*/
public static boolean fileCopy(File sourceFile, File targetFile) {
boolean success = true;
try {
FileInputStream in = new FileInputStream(sourceFile);
FileOutputStream out = new FileOutputStream(targetFile);
byte[] buffer = new byte[1024];
int len = 0;
while ((len = in.read(buffer)) > 0) {
out.write(buffer);
}
in.close();
out.close();
} catch (FileNotFoundException e) {
success = false;
} catch (IOException e) {
success = false;
}
return success;
}
/**
* 文件重命名
*
* @param toBeRenamed 将要修改名字的文件
* @param toFileNewName 新的名字
* @return
*/
public static boolean renameFile(File toBeRenamed, String toFileNewName) {
//检查要重命名的文件是否存在,是否是文件
if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
log.info("File does not exist: " + toBeRenamed.getName());
return false;
}
String p = toBeRenamed.getParent();
File newFile = new File(p + File.separatorChar + toFileNewName);
//修改文件名
return toBeRenamed.renameTo(newFile);
}
}
如有错误请大家指正,有相关侵权请联系删除