第一版
文件分片上传过程总结
整个文件分片上传过程分为三个主要步骤:预上传、分片上传和获取已上传分块信息。以下是每个步骤的详细描述:
1. 预上传(preUploadVideo
)
- 功能:生成唯一的上传 ID,并将文件信息存储到 Redis 中,为后续的分片上传做准备。
- 流程:
- 从前端接收文件名(
fileName
)和分片总数(chunks
)。 - 从 Redis 中获取当前用户的用户信息(
TokenUserInfoDto
)。 - 调用
redisComponent.savePreVideoFileInfo
方法:- 生成唯一的上传 ID(
uploadId
)。 - 创建
UploadingFileDto
对象,存储文件的基本信息(文件名、分片总数、初始分片索引等)。 - 根据当前日期和用户 ID 生成存储路径,并确保路径存在。
- 将文件信息存储到 Redis 中,并设置过期时间。
- 生成唯一的上传 ID(
- 返回生成的上传 ID 给前端,用于后续的分片上传。
- 从前端接收文件名(
//预上传文件@RequestMapping("/preUploadVideo")public ResponseVO preUploadVideo(@NotEmpty String fileName, @NotNull Integer chunks) {//从redis中获取用户信息TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();//将预上传的文件信息存入redis,并放回相对应的上传idString uploadId = redisComponent.savePreVideoFileInfo(tokenUserInfoDto.getUserId(), fileName, chunks);//将id放回给前端,为之后的正式上传做准备return getSuccessResponseVO(uploadId);}
2. 分片上传(uploadVideo
)
-
功能:接收分片文件,将其存储到指定路径,并更新 Redis 中的上传进度。
-
流程:
- 从前端接收分片文件(
chunkFile
)、分片索引(chunkIndex
)和上传 ID(uploadId
)。 - 从 Redis 中获取当前用户的用户信息(
TokenUserInfoDto
)。 - 根据上传 ID 获取对应的文件信息(
UploadingFileDto
):- 如果文件信息不存在,抛出异常提示前端重新上传。
- 检查文件大小是否超过系统设置的最大文件大小限制:
- 如果超过限制,抛出异常。
- 构造存储路径,并将分片文件存储到对应路径:
- 路径格式为:
项目根目录/文件夹/临时文件夹/日期/用户ID上传ID/分片索引
。
- 路径格式为:
- 更新 Redis 中的文件信息:
- 增加已上传的分片索引。
- 更新已上传的文件大小。
- 返回成功响应给前端。
//上传文件@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
)
-
功能:返回已上传的分片信息,用于前端判断哪些分片已经上传成功。
-
流程:
- 从前端接收上传 ID(
uploadId
)。 - 从 Redis 中获取当前用户的用户信息(
TokenUserInfoDto
)。 - 根据上传 ID 获取对应的文件信息(
UploadingFileDto
):- 如果文件信息不存在,抛出异常提示前端重新上传。
- 返回文件信息给前端,包括已上传的分片索引和文件大小。
// 获取已上传分块信息@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);}
- 从前端接收上传 ID(
Redis 相关操作
-
savePreVideoFileInfo
方法:- 生成唯一的上传 ID。
- 创建
UploadingFileDto
对象,存储文件的基本信息。 - 根据当前日期和用户 ID 生成存储路径,并确保路径存在。
- 将文件信息存储到 Redis 中,并设置过期时间。
-
updateVideoFileInfo
方法:- 更新 Redis 中的文件信息,包括已上传的分片数和文件大小。
-
getUploadingVideoFile
方法:- 根据用户 ID 和上传 ID 从 Redis 中获取文件信息。
public String savePreVideoFileInfo(String userId, String fileName, Integer chunks) {//生成上传idString uploadId = StringTools.getRandomString(Constants.LENGTH_15);//生成将要上传的文件对应的实体UploadingFileDto fileDto = new UploadingFileDto();//设置分片大小fileDto.setChunks(chunks);//设置文件名fileDto.setFileName(fileName);//设置上传idfileDto.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
)
- 上传 ID(
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();// 生成上传IDString uploadId = redisComponent.savePreVideoFileInfo(tokenUserInfoDto.getUserId(), fileName, chunks);// 返回上传ID给前端return getSuccessResponseVO(uploadId);
}// 在RedisComponent中
public String savePreVideoFileInfo(String userId, String fileName, Integer chunks) {// 生成唯一的上传IDString 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();}// 将文件信息存储到RedisredisUtils.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集合来记录已上传的分片索引,可以在合并文件时快速检查分片的完整性和连续性,并支持断点续传功能。
优化后的代码的优点和不足之处
优点
-
高效记录与检查已上传分片:
- 使用Redis的Set数据结构来记录已上传的分片索引,可以快速进行插入、删除和查找操作,时间复杂度接近O(1),提高了效率。
-
支持断点续传:
- 前端可以获取已上传的分片信息,只上传未完成的分片,提升了用户体验,特别是在网络不稳定或上传中断的情况下。
-
完整性校验:
- 在合并文件时,通过比较Redis Set集合的大小和预期的分片总数,以及检查分片索引的连续性,确保文件的完整性。
-
文件大小校验:
- 合并文件后,检查合并后的文件大小是否与预上传时的总文件大小一致,进一步确保文件的完整性。
-
资源清理:
- 在文件合并成功后,清理临时存储的分片文件和Redis中的相关记录,避免资源浪费。
-
扩展性:
- 使用Redis进行数据存储和管理,便于扩展和维护,可以轻松地与其他微服务或分布式系统集成。
不足之处
-
Redis性能问题:
- 当分片数量非常大时,Redis的Set数据结构可能会占用较多内存,影响性能。虽然Redis本身是内存数据库,性能较高,但大规模数据操作仍可能导致延迟增加。
-
数据一致性问题:
- 如果在上传过程中服务器宕机或出现其他异常情况,可能会导致Redis中的数据与实际上传的文件不一致。需要额外的机制来确保数据的一致性。
-
并发上传问题:
- 如果多个用户同时上传大量文件,可能会导致Redis和服务器的存储压力增大,需要考虑并发控制和资源限制。
-
错误处理不够完善:
- 当前代码在某些异常情况下的处理不够完善,例如网络中断、存储路径错误等,需要进一步增强错误处理和恢复机制。
-
哈希校验缺失:
- 虽然代码中没有实现,但为了进一步确保文件的完整性,可以考虑在分片上传时使用哈希校验,确保每个分片的内容未被篡改。
-
文件合并的效率问题:
- 当分片数量非常多时,合并文件的过程可能会比较耗时,特别是在磁盘I/O性能较低的情况下。
总结
优化后的代码在功能上较为完善,支持断点续传、完整性校验和资源清理等功能,但在处理大规模数据、并发上传和异常情况时仍存在一些不足。在实际应用中,可以根据具体需求和场景进一步优化和改进。