第一版

文件分片上传过程总结

整个文件分片上传过程分为三个主要步骤:预上传、分片上传和获取已上传分块信息。以下是每个步骤的详细描述:

1. 预上传(preUploadVideo
  • 功能:生成唯一的上传 ID,并将文件信息存储到 Redis 中,为后续的分片上传做准备。
  • 流程
    1. 从前端接收文件名(fileName)和分片总数(chunks)。
    2. 从 Redis 中获取当前用户的用户信息(TokenUserInfoDto)。
    3. 调用 redisComponent.savePreVideoFileInfo 方法:
      • 生成唯一的上传 ID(uploadId)。
      • 创建 UploadingFileDto 对象,存储文件的基本信息(文件名、分片总数、初始分片索引等)。
      • 根据当前日期和用户 ID 生成存储路径,并确保路径存在。
      • 将文件信息存储到 Redis 中,并设置过期时间。
    4. 返回生成的上传 ID 给前端,用于后续的分片上传。
//预上传文件
    @RequestMapping("/preUploadVideo")
    public ResponseVO preUploadVideo(@NotEmpty String fileName, @NotNull Integer chunks) {
        //从redis中获取用户信息
        TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
        //将预上传的文件信息存入redis,并放回相对应的上传id
        String uploadId = redisComponent.savePreVideoFileInfo(tokenUserInfoDto.getUserId(), fileName, chunks);
        //将id放回给前端,为之后的正式上传做准备
        return getSuccessResponseVO(uploadId);
    }
2. 分片上传(uploadVideo
  • 功能:接收分片文件,将其存储到指定路径,并更新 Redis 中的上传进度。

  • 流程

    1. 从前端接收分片文件(chunkFile)、分片索引(chunkIndex)和上传 ID(uploadId)。
    2. 从 Redis 中获取当前用户的用户信息(TokenUserInfoDto)。
    3. 根据上传 ID 获取对应的文件信息(UploadingFileDto):
      • 如果文件信息不存在,抛出异常提示前端重新上传。
    4. 检查文件大小是否超过系统设置的最大文件大小限制:
      • 如果超过限制,抛出异常。
    5. 构造存储路径,并将分片文件存储到对应路径:
      • 路径格式为:项目根目录/文件夹/临时文件夹/日期/用户ID上传ID/分片索引
    6. 更新 Redis 中的文件信息:
      • 增加已上传的分片索引。
      • 更新已上传的文件大小。
    7. 返回成功响应给前端。
    //上传文件
      @RequestMapping("/uploadVideo")
      public ResponseVO uploadVideo(@NotNull MultipartFile chunkFile, @NotNull Integer chunkIndex, @NotEmpty String uploadId) throws IOException {
          //获得当前用户信息
          TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
          //获得对应的上传文件的实体
          UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(tokenUserInfoDto.getUserId(), uploadId);
          if (fileDto == null) {
              throw new BusinessException("文件不存在请重新上传");
          }
          SysSettingDto sysSettingDto = redisComponent.getSysSettingDto();
          if (fileDto.getFileSize() > sysSettingDto.getVideoSize() * Constants.MB_SIZE) {
              throw new BusinessException("文件超过最大文件限制");
          }
          //获得路径
          String folder = appConfig.getProjectFolder()
                  + Constants.FILE_FOLDER
                  + Constants.FILE_FOLDER_TEMP
                  + fileDto.getFilePath();
          //创建文件
          File targetFile = new File(folder + "/" + chunkIndex);
          chunkFile.transferTo(targetFile);
          //记录文件上传的分片数
          fileDto.setChunkIndex(chunkIndex);
          //设置当前已上传文件的大小
          fileDto.setFileSize(fileDto.getFileSize() + chunkFile.getSize());
          //更新文件实体信息
          redisComponent.updateVideoFileInfo(tokenUserInfoDto.getUserId(), fileDto);
          return getSuccessResponseVO(null);
      }
    
3. 获取已上传分块信息(getUploadedChunks
  • 功能:返回已上传的分片信息,用于前端判断哪些分片已经上传成功。

  • 流程

    1. 从前端接收上传 ID(uploadId)。
    2. 从 Redis 中获取当前用户的用户信息(TokenUserInfoDto)。
    3. 根据上传 ID 获取对应的文件信息(UploadingFileDto):
      • 如果文件信息不存在,抛出异常提示前端重新上传。
    4. 返回文件信息给前端,包括已上传的分片索引和文件大小。
    // 获取已上传分块信息
      @RequestMapping("/getUploadedChunks")
      public ResponseVO getUploadedChunks(@NotEmpty String uploadId) {
          TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
          UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(tokenUserInfoDto.getUserId(), uploadId);
    
          if (fileDto == null) {
              throw new BusinessException("文件不存在请重新上传");
          }
    
          return getSuccessResponseVO(fileDto);
      }
    

Redis 相关操作

  • savePreVideoFileInfo 方法

    • 生成唯一的上传 ID。
    • 创建 UploadingFileDto 对象,存储文件的基本信息。
    • 根据当前日期和用户 ID 生成存储路径,并确保路径存在。
    • 将文件信息存储到 Redis 中,并设置过期时间。
  • updateVideoFileInfo 方法

    • 更新 Redis 中的文件信息,包括已上传的分片数和文件大小。
  • getUploadingVideoFile 方法

    • 根据用户 ID 和上传 ID 从 Redis 中获取文件信息。
public String savePreVideoFileInfo(String userId, String fileName, Integer chunks) {
        //生成上传id
        String uploadId = StringTools.getRandomString(Constants.LENGTH_15);
        //生成将要上传的文件对应的实体
        UploadingFileDto fileDto = new UploadingFileDto();
        //设置分片大小
        fileDto.setChunks(chunks);
        //设置文件名
        fileDto.setFileName(fileName);
        //设置上传id
        fileDto.setUploadId(uploadId);
        //设置初始分片索引
        fileDto.setChunkIndex(0);

        //根据天数新建目录,根据用户id和上传id生成文件路径
        String day = DateUtil.format(new Date(), DateTimePatternEnum.YYYYMMDD.getPattern());
        String filePath = day + "/" + userId + uploadId;

        String folder = appConfig.getProjectFolder()
                    + Constants.FILE_FOLDER
                    + Constants.FILE_FOLDER_TEMP
                    + filePath;
        File folderFile = new File(folder);
        if (!folderFile.exists()) {
            folderFile.mkdirs();
        }
        //设置对应文件真实路径
        fileDto.setFilePath(filePath);
        //设置预上传的预留时间
        redisUtils.setex(Constants.REDIS_KEY_UPLOADING_FILE + userId + uploadId, fileDto, Constants.REDIS_KEY_EXPIRES_DAY);
        return uploadId;
    }

    public void updateVideoFileInfo(String userId, UploadingFileDto fileDto) {
        redisUtils.setex(Constants.REDIS_KEY_UPLOADING_FILE + userId + fileDto.getUploadId(), fileDto, Constants.REDIS_KEY_EXPIRES_DAY);
    }

    public UploadingFileDto getUploadingVideoFile(String userId, String uploadId) {
        //通过userId和uploadId获得当前上传文件的实体
        return (UploadingFileDto) redisUtils.get(Constants.REDIS_KEY_UPLOADING_FILE + userId + uploadId);
    }

UploadingFileDto

  • 用于存储上传文件的相关信息,包括:
    • 上传 ID(uploadId
    • 文件名(fileName
    • 已上传的分片索引(chunkIndex
    • 总分片数(chunks
    • 文件大小(fileSize
    • 文件路径(filePath
public class UploadingFileDto implements Serializable {
    private String uploadId;
    private String fileName;
    private Integer chunkIndex;
    private Integer chunks;
    private Long fileSize = 0L;
    private String filePath;
    }

总结

整个文件分片上传过程通过预上传生成唯一的上传 ID 和文件信息,分片上传将每个分片存储到指定路径并更新上传进度,最后通过获取已上传分块信息接口返回前端已上传的分片信息。整个过程利用 Redis 存储文件信息,确保上传过程的高效和可靠。

第二版

使用Redis中的Set结构验证每个分块是否缺失
以下是使用Redis Set集合优化后的代码示例:

1. 预上传文件(preUploadVideo 方法)

@RequestMapping("/preUploadVideo")
public ResponseVO preUploadVideo(@NotEmpty String fileName, @NotNull Integer chunks) {
    // 从redis中获取用户信息
    TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
    // 生成上传ID
    String uploadId = redisComponent.savePreVideoFileInfo(tokenUserInfoDto.getUserId(), fileName, chunks);
    // 返回上传ID给前端
    return getSuccessResponseVO(uploadId);
}

// 在RedisComponent中
public String savePreVideoFileInfo(String userId, String fileName, Integer chunks) {
    // 生成唯一的上传ID
    String uploadId = StringTools.getRandomString(Constants.LENGTH_15);
    // 创建UploadingFileDto对象,存储文件基本信息
    UploadingFileDto fileDto = new UploadingFileDto();
    fileDto.setUploadId(uploadId);
    fileDto.setFileName(fileName);
    fileDto.setChunks(chunks);
    fileDto.setChunkIndex(0);
    fileDto.setFileSize(0L);
    // 设置存储路径
    String day = DateUtil.format(new Date(), DateTimePatternEnum.YYYYMMDD.getPattern());
    String filePath = day + "/" + userId + uploadId;
    fileDto.setFilePath(filePath);
    // 创建存储目录
    String folderPath = appConfig.getProjectFolder()
            + Constants.FILE_FOLDER
            + Constants.FILE_FOLDER_TEMP
            + filePath;
    File folder = new File(folderPath);
    if (!folder.exists()) {
        folder.mkdirs();
    }
    // 将文件信息存储到Redis
    redisUtils.setex(Constants.REDIS_KEY_UPLOADING_FILE + userId + uploadId, fileDto, Constants.REDIS_KEY_EXPIRES_DAY);
    return uploadId;
}

2. 分片上传(uploadVideo 方法)

@RequestMapping("/uploadVideo")
public ResponseVO uploadVideo(@NotNull MultipartFile chunkFile, @NotNull Integer chunkIndex, @NotEmpty String uploadId) throws IOException {
    // 获取当前用户信息
    TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
    String userId = tokenUserInfoDto.getUserId();
    // 获取上传文件信息
    UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(userId, uploadId);
    if (fileDto == null) {
        throw new BusinessException("文件不存在请重新上传");
    }
    // 检查文件大小是否超过限制
    SysSettingDto sysSettingDto = redisComponent.getSysSettingDto();
    if (fileDto.getFileSize() + chunkFile.getSize() > sysSettingDto.getVideoSize() * Constants.MB_SIZE) {
        throw new BusinessException("文件超过最大文件限制");
    }
    // 构造存储路径并保存分片文件
    String folderPath = appConfig.getProjectFolder()
            + Constants.FILE_FOLDER
            + Constants.FILE_FOLDER_TEMP
            + fileDto.getFilePath();
    File targetFile = new File(folderPath + "/" + chunkIndex);
    chunkFile.transferTo(targetFile);
    // 更新文件信息
    fileDto.setChunkIndex(chunkIndex);
    fileDto.setFileSize(fileDto.getFileSize() + chunkFile.getSize());
    redisComponent.updateVideoFileInfo(userId, fileDto);
    
    // 将分片索引加入Redis Set集合
    redisUtils.sadd(Constants.REDIS_KEY_UPLOADED_CHUNKS + uploadId, chunkIndex);
    return getSuccessResponseVO(null);
}

3. 合并文件(mergeVideo 方法)

@RequestMapping("/mergeVideo")
public ResponseVO mergeVideo(@NotEmpty String uploadId) {
    TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
    String userId = tokenUserInfoDto.getUserId();
    // 获取上传文件信息
    UploadingFileDto fileDto = redisComponent.getUploadingVideoFile(userId, uploadId);
    if (fileDto == null) {
        throw new BusinessException("文件不存在请重新上传");
    }
    // 获取已上传的分片索引
    Set<Integer> uploadedChunks = redisUtils.smembers(Constants.REDIS_KEY_UPLOADED_CHUNKS + uploadId);
    // 检查分片是否完整
    if (uploadedChunks.size() != fileDto.getChunks()) {
        // 找出缺失的分片索引
        Set<Integer> missingChunks = new HashSet<>();
        for (int i = 0; i < fileDto.getChunks(); i++) {
            if (!uploadedChunks.contains(i)) {
                missingChunks.add(i);
            }
        }
        throw new BusinessException("存在未上传的分片:" + missingChunks);
    }
    // 检查分片索引的连续性
    List<Integer> sortedChunks = new ArrayList<>(uploadedChunks);
    Collections.sort(sortedChunks);
    for (int i = 0; i < sortedChunks.size(); i++) {
        if (sortedChunks.get(i) != i) {
            throw new BusinessException("分片索引不连续,存在缺失的分片");
        }
    }
    // 构造存储路径
    String tempFolderPath = appConfig.getProjectFolder()
            + Constants.FILE_FOLDER
            + Constants.FILE_FOLDER_TEMP
            + fileDto.getFilePath();
    String finalFilePath = appConfig.getProjectFolder()
            + Constants.FILE_FOLDER
            + Constants.FILE_FOLDER_VIDEO
            + fileDto.getFilePath();
    // 合并分片文件
    File finalFile = new File(finalFilePath);
    try (FileOutputStream out = new FileOutputStream(finalFile)) {
        for (int i = 0; i < fileDto.getChunks(); i++) {
            File chunkFile = new File(tempFolderPath + "/" + i);
            if (chunkFile.exists()) {
                try (FileInputStream in = new FileInputStream(chunkFile)) {
                    byte[] buffer = new byte[1024];
                    int bytesRead;
                    while ((bytesRead = in.read(buffer)) != -1) {
                        out.write(buffer, 0, bytesRead);
                    }
                }
            }
        }
    } catch (IOException e) {
        throw new BusinessException("文件合并失败", e);
    }
    // 检查合并后的文件大小是否与预上传时的总文件大小一致
    long mergedFileSize = finalFile.length();
    if (mergedFileSize != fileDto.getFileSize()) {
        throw new BusinessException("合并后的文件大小不匹配,存在缺失的分片");
    }
    // 清理临时文件和Redis中的记录
    File tempFolder = new File(tempFolderPath);
    if (tempFolder.exists()) {
        deleteDirectory(tempFolder);
    }
    redisUtils.del(Constants.REDIS_KEY_UPLOADING_FILE + userId + uploadId);
    redisUtils.del(Constants.REDIS_KEY_UPLOADED_CHUNKS + uploadId);
    return getSuccessResponseVO(finalFilePath);
}

4. 获取已上传分块信息(getUploadedChunks 方法)

@RequestMapping("/getUploadedChunks")
public ResponseVO getUploadedChunks(@NotEmpty String uploadId) {
    Set<Integer> uploadedChunks = redisUtils.smembers(Constants.REDIS_KEY_UPLOADED_CHUNKS + uploadId);
    return getSuccessResponseVO(uploadedChunks);
}

5. Redis 工具类方法

// 添加元素到Set集合
public void sadd(String key, Integer value) {
    redisTemplate.opsForSet().add(key, value);
}

// 获取Set集合中的所有元素
public Set<Integer> smembers(String key) {
    return redisTemplate.opsForSet().members(key);
}

// 删除键值对
public void del(String key) {
    redisTemplate.delete(key);
}

通过以上代码,利用Redis的Set集合来记录已上传的分片索引,可以在合并文件时快速检查分片的完整性和连续性,并支持断点续传功能。

优化后的代码的优点和不足之处

优点
  1. 高效记录与检查已上传分片

    • 使用Redis的Set数据结构来记录已上传的分片索引,可以快速进行插入、删除和查找操作,时间复杂度接近O(1),提高了效率。
  2. 支持断点续传

    • 前端可以获取已上传的分片信息,只上传未完成的分片,提升了用户体验,特别是在网络不稳定或上传中断的情况下。
  3. 完整性校验

    • 在合并文件时,通过比较Redis Set集合的大小和预期的分片总数,以及检查分片索引的连续性,确保文件的完整性。
  4. 文件大小校验

    • 合并文件后,检查合并后的文件大小是否与预上传时的总文件大小一致,进一步确保文件的完整性。
  5. 资源清理

    • 在文件合并成功后,清理临时存储的分片文件和Redis中的相关记录,避免资源浪费。
  6. 扩展性

    • 使用Redis进行数据存储和管理,便于扩展和维护,可以轻松地与其他微服务或分布式系统集成。
不足之处
  1. Redis性能问题

    • 当分片数量非常大时,Redis的Set数据结构可能会占用较多内存,影响性能。虽然Redis本身是内存数据库,性能较高,但大规模数据操作仍可能导致延迟增加。
  2. 数据一致性问题

    • 如果在上传过程中服务器宕机或出现其他异常情况,可能会导致Redis中的数据与实际上传的文件不一致。需要额外的机制来确保数据的一致性。
  3. 并发上传问题

    • 如果多个用户同时上传大量文件,可能会导致Redis和服务器的存储压力增大,需要考虑并发控制和资源限制。
  4. 错误处理不够完善

    • 当前代码在某些异常情况下的处理不够完善,例如网络中断、存储路径错误等,需要进一步增强错误处理和恢复机制。
  5. 哈希校验缺失

    • 虽然代码中没有实现,但为了进一步确保文件的完整性,可以考虑在分片上传时使用哈希校验,确保每个分片的内容未被篡改。
  6. 文件合并的效率问题

    • 当分片数量非常多时,合并文件的过程可能会比较耗时,特别是在磁盘I/O性能较低的情况下。

总结

优化后的代码在功能上较为完善,支持断点续传、完整性校验和资源清理等功能,但在处理大规模数据、并发上传和异常情况时仍存在一些不足。在实际应用中,可以根据具体需求和场景进一步优化和改进。

Logo

永洪科技,致力于打造全球领先的数据技术厂商,具备从数据应用方案咨询、BI、AIGC智能分析、数字孪生、数据资产、数据治理、数据实施的端到端大数据价值服务能力。

更多推荐