结合redis实现文件分片秒传断点续传
# 工作流程
#
1. **秒传**
- - 前端计算文件MD5并请求` /check `
- Redis中存在MD5记录 → 直接返回成功
2. **分片上传**
- - 检查分片是否已上传(通过Redis Set)
- 只上传缺失分片
- 每个分片独立保存到临时目录
3. **断点续传**
- - 刷新页面后重新计算MD5
- 检查已上传分片列表
- 继续上传剩余分片
4. **合并文件**
- - 所有分片上传完成后触发合并
- 按索引顺序合并分片
- 清理临时文件和Redis记录
# 实现的思路(容易懂一点的)
1.前端计算上传文件的MD5值,若redis中有对应的MD5值,则直接说明秒传成功
2.后端通过redis记录文件分片详情,我这里使用的是set,可以根据需要使用不同的类型
3.前端开始上传后,每上传成功一段分片都要记录在redis,若此时暂停上传且此时分片未上传完成则视为分片未成功上传,不需要在redis记录
4.续传后,前端重新计算MD5值,重新开始上传redis中记录缺失的分片
5.上传成功后,对于分片的记录数进行删除,以文件MD5值为value重新记录说明此文件已成功上传(此处是为了实现秒传)
6.这里我多加了一个oss,当我上传文件成功后我调用oss工具类将其上传到oss得到返回的url给前端
下面是后端的接口实现代码(代码仅供参考,可以直接使用但功能并不完善,仅作demo):
注意:文件路径记得改成自己的(建议单独建一个文件夹或者使用临时文件)
# 秒传验证&检查分片
#
```
@PostMapping("/check")
public ResponseEntity<?> checkFile(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunkIndex") Integer chunkIndex) {
// 1. 检查文件是否已存在 (秒传)
if (Boolean.TRUE.equals(redisTemplate.hasKey("FILE_MD5:" + fileMd5))) {
return ResponseEntity.ok(Map.of("exist", true, "uploaded", true));
}
// 2. 检查当前分片是否已上传
String chunkKey = "CHUNKS:" + fileMd5;
Boolean isChunkUploaded = redisTemplate.opsForSet().isMember(chunkKey, chunkIndex);
return ResponseEntity.ok(Map.of(
"exist", false,
"chunkUploaded", Boolean.TRUE.equals(isChunkUploaded)
));
}
```
# 分片上传
#
```
@PostMapping("/upload")
public ResponseEntity<?> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunkIndex") Integer chunkIndex) throws IOException {
// 1. 创建临时目录
File chunkDir = new File(tempDir + fileMd5);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 2. 保存分片文件
File chunkFile = new File(chunkDir, chunkIndex.toString());
file.transferTo(chunkFile);
// 3. 在Redis中记录已上传分片
String chunkKey = "CHUNKS:" + fileMd5;
redisTemplate.opsForSet().add(chunkKey, chunkIndex);
return ResponseEntity.ok().build();
}
```
# 合并分片
#
```
@PostMapping("/merge")
public ResponseEntity<?> mergeChunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") Integer totalChunks) throws IOException {
// 1. 验证是否所有分片已上传
String chunkKey = "CHUNKS:" + fileMd5;
Long uploadedCount = redisTemplate.opsForSet().size(chunkKey);
if (uploadedCount == null || uploadedCount != (long) totalChunks) {
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).build();
}
// 2. 合并文件
File destFile = new File("E:/code/file/final/" + fileName); // 推荐使用正斜杠
if (!destFile.getParentFile().exists()) {
destFile.getParentFile().mkdirs();
}
try (FileOutputStream fos = new FileOutputStream(destFile)) {
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(tempDir + fileMd5, String.valueOf(i));
FileUtils.copyFile(chunkFile, fos);
chunkFile.delete(); // 删除分片
}
}
// ✅ 新增:将合并后的文件转为 MultipartFile 并上传 OSS
MultipartFile multipartFile = convertToMultipartFile(destFile, fileName);
String ossUrl = ossUtil.uploadMultipartFile(multipartFile);
// 3. 清理临时目录
FileUtils.deleteDirectory(new File(tempDir + fileMd5));
// 4. 记录文件MD5(秒传标识)
redisTemplate.opsForValue().set("FILE_MD5:" + fileMd5, ossUrl); // 存储的是 OSS 地址
// 5. 清理Redis分片记录
redisTemplate.delete(chunkKey);
// 6. 删除本地合并后的文件(可选)
destFile.delete();
log.info("文件url:"+ossUrl);
// 7. 返回 OSS URL 给前端
return ResponseEntity.ok(Map.of(
"url", ossUrl
));
}
//文件转换类
private MultipartFile convertToMultipartFile(File file, String originalFilename) throws IOException {
String contentType = Files.probeContentType(file.toPath());
byte[] content = Files.readAllBytes(file.toPath());
return new CustomMultipartFile(content, originalFilename, contentType);
}
```
# 文件转换工具类
#
```
public class CustomMultipartFile implements MultipartFile {
private final byte[] content;
private final String originalFilename;
private final String contentType;
public CustomMultipartFile(byte[] content, String originalFilename, String contentType) {
this.content = content;
this.originalFilename = originalFilename;
this.contentType = contentType;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return originalFilename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() throws IOException {
return content;
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(File dest) throws IOException, IllegalStateException {
Files.write(dest.toPath(), content);
}
}
```
# 下面是oss配置类和工具类:
#
```
/**
* OSS 文件管理服务
*/
@Log4j2
@Component
public class OssUtil {
/** 自动注入 OssConfig 类型的 Bean */
@Autowired
private OssConfig ossConfig;
/** 定义访问前缀,用于构建文件的完整访问路径 */
@Value("${aliyun.oss.accessPre}")
private String accessPre;
/** 定义存储桶名称,方便在上传和下载时引用 */
@Value("${aliyun.oss.bucketName}")
private String bucketName;
/**
* 默认路径上传本地文件
*
* @param filePath 本地文件路径
* @return 上传后的文件访问路径
*/
public String uploadFile(String filePath) {
return uploadFileForBucket(bucketName, getOssFilePath(filePath), filePath);
}
/**
* 默认路径上传 MultipartFile 文件
*
* @param multipartFile 待上传的文件
* @return 上传后的文件访问路径
*/
public String uploadMultipartFile(MultipartFile multipartFile) {
return uploadMultipartFile(bucketName, getOssFilePath(multipartFile.getOriginalFilename()), multipartFile);
}
/**
* 上传 MultipartFile 类型文件到指定 Bucket
*
* @param bucketName 实例名称
* @param ossPath OSS 存储路径
* @param multipartFile 待上传的文件
* @return 上传后的文件访问路径
*/
public String uploadMultipartFile(String bucketName, String ossPath, MultipartFile multipartFile) {
try (InputStream inputStream = multipartFile.getInputStream()) {
uploadFileInputStreamForBucket(bucketName, ossPath, inputStream);
} catch (IOException e) {
log.error("上传文件失败: {}", e.getMessage(), e);
return null;
}
return accessPre + ossPath;
}
/**
* 使用 File 上传文件
*
* @param bucketName 实例名称
* @param ossPath OSS 存储路径
* @param filePath 本地文件路径
* @return 上传后的文件访问路径
*/
public String uploadFileForBucket(String bucketName, String ossPath, String filePath) {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, ossPath, new File(filePath));
ossConfig.init().putObject(putObjectRequest);
return accessPre + ossPath;
}
/**
* 使用文件流上传到指定的 Bucket 实例
*
* @param bucketName 实例名称
* @param ossPath OSS 存储路径
* @param inputStream 文件输入流
*/
public void uploadFileInputStreamForBucket(String bucketName, String ossPath, InputStream inputStream) {
ossConfig.init().putObject(bucketName, ossPath, inputStream);
}
/**
* 下载文件
*
* @param ossFilePath OSS 存储路径
* @param filePath 本地文件路径
*/
public void downloadFile(String ossFilePath, String filePath) {
downloadFileForBucket(bucketName, ossFilePath, filePath);
}
/**
* 从指定 Bucket 下载文件
*
* @param bucketName 实例名称
* @param ossFilePath OSS 存储路径
* @param filePath 本地文件路径
*/
public void downloadFileForBucket(String bucketName, String ossFilePath, String filePath) {
ossConfig.init().getObject(new GetObjectRequest(bucketName, ossFilePath), new File(filePath));
}
/**
* 获取默认 OSS 存储路径
*
* @return 默认 OSS 存储路径
*/
public String getOssDefaultPath() {
LocalDateTime now = LocalDateTime.now();
return String.format("%d/%d/%d/%d/%d/",
now.getYear(),
now.getMonthValue(),
now.getDayOfMonth(),
now.getHour(),
now.getMinute());
}
/**
* 生成 OSS 文件路径
*
* @param filePath 本地文件路径
* @return OSS 文件路径
*/
public String getOssFilePath(String filePath) {
String fileSuffix = filePath.substring(filePath.lastIndexOf(".") + 1);
return getOssDefaultPath() + UUID.randomUUID() + "." + fileSuffix;
}
}
```
```
/**
* OSS初始化配置
*/
@Log4j2
@Configuration
public class OssConfig {
/**
* 配置文件中读取阿里云 OSS 的 endpoint,注入到 endPoint 变量中
*/
@Value("${aliyun.oss.endPoint}")
private String endPoint;
/**
* 从配置文件中读取阿里云 OSS 的 accessKeyId,注入到 accessKeyId 变量中
*/
@Value("${aliyun.oss.accessKeyId}")
private String accessKeyId;
/**
* 从配置文件中读取阿里云 OSS 的 accessKeySecret,注入到 accessKeySecret 变量中
*/
@Value("${aliyun.oss.accessKeySecret}")
private String accessKeySecret;
private OSS ossClient;
@Bean
public OSS init() {
// 如果 OSS 客户端尚未初始化,则进行初始化
if (ossClient == null) {
// 使用 OSSClientBuilder 构建 OSS 客户端,传入 endpoint、accessKeyId 和 accessKeySecret
ossClient = createOSSClient();
// 记录日志,表示连接成功
log.info("OSS服务连接成功!");
}
// 返回初始化好的 OSS 客户端实例
return ossClient;
}
/**
* 创建 OSS 客户端的方法
*/
private OSS createOSSClient() {
return new OSSClientBuilder().build(endPoint, accessKeyId, accessKeySecret);
}
@PreDestroy
public void destroy() {
// 关闭 OSS 客户端
if (ossClient != null) {
// 调用 shutdown() 方法关闭 OSS 客户端
ossClient.shutdown();
// 记录日志,确认客户端已成功关闭
log.info("OSS客户端已成功关闭。");
}
}
}
```
```
<!--aliyun-oss-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
```
# 下面是前端代码(提供示例代码、仅供参考、完整代码地址看最下面):
# ```
<template>
<div>
<input type="file" @change="handleFileSelect" ref="fileInput">
<el-button @click="startUpload">开始上传</el-button>
<div>进度: {{ progress }}%</div>
</div>
</template>
<script>
import axios from 'axios';
import SparkMD5 from 'spark-md5'; // npm install spark-md5
export default {
data() {
return {
file: null,
fileMd5: '',
chunkSize: 2 * 1024 * 1024, // 2MB分片
totalChunks: 0,
uploadedChunks: new Set(),
progress: 0
};
},
methods: {
async handleFileSelect(e) {
this.file = e.target.files[0];
this.totalChunks = Math.ceil(this.file.size / this.chunkSize);
// 计算文件MD5(秒传关键)
this.fileMd5 = await this.calculateMd5();
},
calculateMd5() {
return new Promise((resolve) => {
const blobSlice = File.prototype.slice;
const chunks = this.totalChunks;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let currentChunk = 0;
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
const loadNext = () => {
const start = currentChunk * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
fileReader.readAsArrayBuffer(blobSlice.call(this.file, start, end));
};
loadNext();
});
},
async startUpload() {
// 1. 秒传检查
const { data } = await axios.post('/api/file/check', {
fileMd5: this.fileMd5,
chunkIndex: 0
});
if (data.exist && data.uploaded) {
this.progress = 100;
alert('秒传成功!');
return;
}
// 2. 上传分片
for (let i = 0; i < this.totalChunks; i++) {
// 跳过已上传分片
if (this.uploadedChunks.has(i)) continue;
const chunk = this.file.slice(
i * this.chunkSize,
Math.min((i + 1) * this.chunkSize, this.file.size)
);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileMd5', this.fileMd5);
formData.append('chunkIndex', i);
await axios.post('/api/file/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// 更新进度
this.uploadedChunks.add(i);
this.progress = Math.round((this.uploadedChunks.size / this.totalChunks) * 100);
}
// 3. 合并请求
await axios.post('/api/file/merge', {
fileMd5: this.fileMd5,
fileName: this.file.name,
totalChunks: this.totalChunks
});
alert('上传完成!');
}
}
};
</script>
```
前端代码地址(麻烦各位大佬点个星星,在下感激不尽):
https://github.com/xuxiaxuan/-/tree/master