分片上传与断点续传实现详解

分片上传与断点续传实现详解

在现代Web应用中,用户经常需要上传大文件,如视频、压缩包等。传统的文件上传方式在面对大文件时容易出现超时、失败等问题,而且一旦上传中断就需要重新上传整个文件,浪费时间和带宽。为了解决这些问题,分片上传和断点续传技术应运而生。

什么是分片上传和断点续传?

分片上传是将一个大文件分割成多个小块(分片),分别上传到服务器,最后在服务器端将这些分片合并成完整文件的技术。

断点续传是指在上传过程中,如果因为网络或其他原因导致上传中断,可以从上次中断的位置继续上传,而不需要重新上传整个文件。

前端操作流程详解

前端在整个分片上传和断点续传过程中起着关键作用,主要负责文件的分片、MD5计算、上传控制等任务。

1. 文件选择与初始化

当用户选择一个大文件后,前端JavaScript代码会执行以下操作:

  1. 获取用户选择的文件对象
  2. 读取文件基本信息(文件名、大小等)
  3. 计算整个文件的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. 显示上传进度条
  2. 处理上传成功/失败事件
  3. 在网络中断时尝试重连
  4. 上传完成后通知用户

后端实现原理

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相关信息

通过这些键值对,系统可以:

  1. 记录每个文件上传的进度
  2. 在上传中断后,能够从断点处继续上传
  3. 避免重复上传已经成功上传的分片

3. 提高查询效率

相比直接读写数据库或文件系统,Redis提供了更快速的数据访问方式,特别是在需要频繁查询上传状态的场景下。

实现流程总结

整个分片上传和断点续传流程如下:

  1. 用户选择文件,前端读取文件信息
  2. 前端计算整个文件的MD5值
  3. 前端调用checkMd5接口检查是否已部分上传
  4. 如果已部分上传,后端返回已上传的文件大小和任务ID
  5. 前端从指定位置开始上传剩余分片
  6. 后端接收每个分片并使用RandomAccessFile从指定位置写入临时文件
  7. 所有分片上传完成后,将临时文件重命名为正式文件
  8. 清理Redis中的临时数据

这种方式可以有效处理大文件上传问题,支持断点续传,在网络中断后可以从中断位置继续上传,避免重复上传已上传的分片,大大提高了大文件上传的效率和用户体验。

posted @ 2025-10-26 11:09  WonderC  阅读(20)  评论(0)    收藏  举报