分片上传与断点续传实现详解
在现代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; // 2MBconst 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(); // 任务IDMultipartFile 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();// 记录分片数和文件路径到RedisredisUtil.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();}// 更新文件信息到RedisString 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;// 计算文件MD5md5 = 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中的临时数据
这种方式可以有效处理大文件上传问题,支持断点续传,在网络中断后可以从中断位置继续上传,避免重复上传已上传的分片,大大提高了大文件上传的效率和用户体验。