分片上传与断点续传实现详解
分片上传与断点续传实现详解
在现代Web应用中,用户经常需要上传大文件,如视频、压缩包等。传统的文件上传方式在面对大文件时容易出现超时、失败等问题,而且一旦上传中断就需要重新上传整个文件,浪费时间和带宽。为了解决这些问题,分片上传和断点续传技术应运而生。
什么是分片上传和断点续传?
分片上传是将一个大文件分割成多个小块(分片),分别上传到服务器,最后在服务器端将这些分片合并成完整文件的技术。
断点续传是指在上传过程中,如果因为网络或其他原因导致上传中断,可以从上次中断的位置继续上传,而不需要重新上传整个文件。
前端操作流程详解
前端在整个分片上传和断点续传过程中起着关键作用,主要负责文件的分片、MD5计算、上传控制等任务。
1. 文件选择与初始化
当用户选择一个大文件后,前端JavaScript代码会执行以下操作:
- 获取用户选择的文件对象
- 读取文件基本信息(文件名、大小等)
- 计算整个文件的MD5值(用于唯一标识文件和断点续传)
2. 文件分片处理
前端需要将大文件按照指定大小进行分片:
// 示例:文件分片逻辑
function createFileChunks(file, chunkSize) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({
index: chunks.length,
chunk: file.slice(cur, cur + chunkSize)
});
cur += chunkSize;
}
return chunks;
}
通常分片大小设置为1MB-10MB之间,根据网络情况和文件大小动态调整。
3. MD5值计算
在上传前,前端需要计算整个文件的MD5值:
// 示例:计算文件MD5
function calculateFileMD5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const chunkSize = 2097152; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
reader.onload = function(e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
const md5 = spark.end();
resolve(md5);
}
};
reader.onerror = function() {
reject('计算MD5出错');
};
function loadNext() {
const start = currentChunk * chunkSize;
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
reader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
}
4. 断点检查
使用计算出的MD5值向服务器查询上传状态:
// 示例:检查断点
async function checkUploadBreakPoint(md5) {
const response = await fetch(`/checkMd5?md5=${md5}`);
return await response.json();
}
服务器会返回:
- 如果文件从未上传过:返回fileSize=0和新的taskId
- 如果文件部分上传:返回已上传的fileSize和原有的taskId
5. 分片上传控制
根据断点检查结果,前端开始上传分片:
// 示例:上传分片
async function uploadChunks(chunks, taskId, md5, breakPoint = 0) {
// 从断点位置开始上传
for (let i = breakPoint; i < chunks.length; i++) {
const formData = new FormData();
formData.append('taskId', taskId);
formData.append('chunkNumber', i + 1);
formData.append('chunkSize', chunks[i].chunk.size);
formData.append('totalChunks', chunks.length);
formData.append('fileSize', computeFileSize(i)); // 计算当前分片的起始位置
formData.append('fileName', fileName);
formData.append('file', chunks[i].chunk);
formData.append('code', businessCode);
const response = await fetch('/chunkUpload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.code !== 200) {
throw new Error('上传失败');
}
}
}
6. 上传状态管理
前端需要管理整个上传过程的状态:
- 显示上传进度条
- 处理上传成功/失败事件
- 在网络中断时尝试重连
- 上传完成后通知用户
后端实现原理
1. 分片上传参数实体类
@Data
public class MultipartFileParam implements Serializable {
private static final long serialVersionUID = 3238600879053243080L;
/**
* 文件传输任务ID
*/
@ApiModelProperty(value = "文件传输任务ID:调用checkMd5返回")
private String taskId;
/**
* 当前为第几分片
*/
@ApiModelProperty(value = "当前为第几分片")
private int chunkNumber;
/**
* 每个分块的大小
*/
@ApiModelProperty(value = "每个分块的大小")
private long chunkSize;
/**
* 分片总数
*/
@ApiModelProperty(value = "分片总数")
private long totalChunks;
/**
* 文件大小
*/
@ApiModelProperty(value = "文件大小:这里只偏移量,断点后的末尾,续传前的开始")
private long fileSize;
/**
* 文件名称
*/
@ApiModelProperty(value = "文件名称")
private String fileName;
/**
* 分块文件传输对象
*/
@ApiModelProperty(value = "分块文件传输对象:分片文件的数据")
private MultipartFile file;
/**
* 业务类型
*/
@ApiModelProperty(value = "业务类型")
private String code;
}
2. Redis键常量定义
public class UpLoadConstant {
private final static String uploading = "Uploading:";
private final static String file = uploading + "file:";
// 当前文件传输到第几块
public final static String chunkNum = file + "chunkNum:";
// 当前文件上传的路径
public final static String localLocation = file + "localLocation:";
public final static String task = uploading + "task:";
public final static String fileMd5 = file + "md5:";
}
3. 分片上传核心实现
public R<Object> uploadAppendFile(MultipartFileParam multipartFileParam) {
Map<String, String> map = new HashMap<>();
// 获取分片参数
long chunk = multipartFileParam.getChunkNumber(); // 当前分片位置
long fileSize = multipartFileParam.getFileSize(); // 文件大小(偏移量)
long totalChunks = multipartFileParam.getTotalChunks(); // 分片总数
String taskId = multipartFileParam.getTaskId(); // 任务ID
MultipartFile file = multipartFileParam.getFile(); // 分片文件
String fileName = multipartFileParam.getFileName(); // 文件名
// 创建上传目录
File folder = new File(getUploadPath(multipartFileParam));
if (!folder.isDirectory() && !folder.mkdirs()) {
log.error("文件夹创建失败");
return R.fail("文件夹创建失败");
}
String localPath = folder.getPath().concat(FileUtil.FILE_SEPARATOR);
RandomAccessFile raf = null;
InputStream is = null;
try {
if (chunk == 1) {
// 第一个分片上传
String tempFileName = taskId + fileName.substring(fileName.lastIndexOf(".")).concat("_tmp");
File fileDir = new File(localPath);
if (!fileDir.exists()) {
fileDir.mkdirs();
}
// 创建临时文件
File tempFile = new File(localPath, tempFileName);
if (!tempFile.exists()) {
tempFile.createNewFile();
}
// 写入第一个分片
raf = new RandomAccessFile(tempFile, "rw");
is = file.getInputStream();
raf.seek(0); // 从文件开头写入
int len = 0;
byte[] bytes = new byte[1024 * 10];
while ((len = is.read(bytes)) != -1) {
raf.write(bytes, 0, len);
}
raf.close();
is.close();
// 记录分片数和文件路径到Redis
redisUtil.setObject(UpLoadConstant.chunkNum + taskId, chunk, cacheTime);
redisUtil.setObject(UpLoadConstant.localLocation + taskId, tempFile.getPath(), cacheTime);
log.info("上传成功");
map.put("result", "上传成功");
} else {
// 续传分片
String path = (String) redisUtil.getObject(UpLoadConstant.localLocation + taskId);
is = file.getInputStream();
raf = new RandomAccessFile(path, "rw");
// 从指定位置开始写入
raf.seek(fileSize);
int len = 0;
byte[] bytes = new byte[1024 * 10];
while ((len = is.read(bytes)) != -1) {
raf.write(bytes, 0, len);
}
redisUtil.setObject(UpLoadConstant.chunkNum + taskId, chunk, cacheTime);
raf.close();
is.close();
}
// 更新文件信息到Redis
String md5 = (String) redisUtil.getObject(UpLoadConstant.task + taskId);
HashMap<String, String> redisMap = new HashMap<>();
redisMap.put("fileSize", fileSize + "");
redisMap.put("taskId", taskId);
redisUtil.setHashAsMap(UpLoadConstant.fileMd5 + md5, redisMap, cacheTime);
// 所有分片上传完成
if (chunk == totalChunks) {
String path = (String) redisUtil.getObject(UpLoadConstant.localLocation + taskId);
String extName = FileUtil.extName(fileName);
String newName = fileName.substring(0,fileName.lastIndexOf("."))
.concat("-")
.concat(Seq.getId(Seq.uploadSeqType))
.concat(".")
.concat(extName);
String newUrl = localConfig.getDomain()
.concat(localConfig.getPrefix())
.concat(basePath)
.concat(newName);
// 重命名临时文件为正式文件
FileUtil.rename(new File(path), newName, true);
log.info("上传完毕");
map.put("result", "上传完毕");
map.put("name", newName);
map.put("url", newUrl);
// 清理Redis中的临时数据
redisUtil.del(UpLoadConstant.fileMd5 + md5);
redisUtil.del(UpLoadConstant.task + taskId);
redisUtil.del(UpLoadConstant.chunkNum + taskId);
redisUtil.del(UpLoadConstant.localLocation + taskId);
}
} catch (IOException e) {
e.printStackTrace();
String md5 = (String) redisUtil.getObject(UpLoadConstant.task + taskId);
redisUtil.del(UpLoadConstant.fileMd5 + md5);
redisUtil.del(UpLoadConstant.task + taskId);
redisUtil.del(UpLoadConstant.chunkNum + taskId);
redisUtil.del(UpLoadConstant.localLocation + taskId);
log.error("上传异常");
map.put("result", "上传异常");
} finally {
// 关闭资源
try {
if (raf != null) {
raf.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return R.ok(map);
}
4. MD5校验与断点检查
public Map<String, Object> checkMd5(String md5) {
Map<String, Object> map = new HashMap<>();
String fileSize = null;
String taskId = null;
// 计算文件MD5
md5 = SecureUtil.md5(md5);
// 从Redis中获取文件信息
Map redisMap = redisUtil.getMap(UpLoadConstant.fileMd5 + md5);
if (MapUtil.isNotEmpty(redisMap)) {
fileSize = redisMap.get("fileSize").toString();
taskId = redisMap.get("taskId").toString();
}
if (StrUtil.isNotEmpty(fileSize)) {
// 文件已部分上传,返回已上传的文件大小
map.put("fileSize", Long.parseLong(fileSize != null ? fileSize : ""));
} else {
// 文件未上传过,创建新的任务
Map<String, Object> map1 = new HashMap<>();
taskId = IdUtil.simpleUUID();
map1.put("fileSize", 0);
map1.put("taskId", taskId);
redisUtil.setHashAsMap(UpLoadConstant.fileMd5 + md5, map1, cacheTime);
redisUtil.setObject(UpLoadConstant.task + taskId, md5, cacheTime);
map.put("fileSize", 0);
}
map.put("taskId", taskId);
return map;
}
Redis在分片上传中的作用
Redis在分片上传和断点续传中扮演着关键角色:
1. 存储上传任务状态
UpLoadConstant.chunkNum + taskId: 记录当前已上传的分片序号UpLoadConstant.localLocation + taskId: 存储临时文件的存储路径
2. 实现断点续传功能
UpLoadConstant.task + taskId: 存储任务ID与文件MD5值的映射关系UpLoadConstant.fileMd5 + md5: 存储文件的MD5相关信息
通过这些键值对,系统可以:
- 记录每个文件上传的进度
- 在上传中断后,能够从断点处继续上传
- 避免重复上传已经成功上传的分片
3. 提高查询效率
相比直接读写数据库或文件系统,Redis提供了更快速的数据访问方式,特别是在需要频繁查询上传状态的场景下。
实现流程总结
整个分片上传和断点续传流程如下:
- 用户选择文件,前端读取文件信息
- 前端计算整个文件的MD5值
- 前端调用checkMd5接口检查是否已部分上传
- 如果已部分上传,后端返回已上传的文件大小和任务ID
- 前端从指定位置开始上传剩余分片
- 后端接收每个分片并使用RandomAccessFile从指定位置写入临时文件
- 所有分片上传完成后,将临时文件重命名为正式文件
- 清理Redis中的临时数据
这种方式可以有效处理大文件上传问题,支持断点续传,在网络中断后可以从中断位置继续上传,避免重复上传已上传的分片,大大提高了大文件上传的效率和用户体验。

浙公网安备 33010602011771号