前端(Vue 3)
1. 文件选择与分片上传
首先,我们需要一个文件选择器来选择要上传的文件,并将其分割成多个小块(分片)进行上传。我们还需要记录每个分片的上传状态以便支持断点续传。
javascript
<template>
<div>
<!-- 文件选择输入框 -->
<form @submit.prevent="handleSubmit">
<input type="file" @change="handleFileChange" ref="fileInput" />
<!-- 文件预览信息 -->
<div v-if="selectedFile">
<p>Selected File: {{ selectedFile.name }} ({{ formatBytes(selectedFile.size) }})</p>
<!-- 进度条显示 -->
<progress :value="uploadProgress" max="100"></progress>
</div>
<!-- 提交按钮 -->
<button type="submit">Submit</button>
</form>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import axios from 'axios';
export default {
setup() {
const fileInput = ref(null);
const selectedFile = ref(null);
const uploadProgress = ref(0);
const uploadedChunks = ref(new Set());
const CHUNK_SIZE = 5 * 1024 * 1024; // 每个分片大小为5MB
/**
* 处理文件选择事件
* @param event 文件选择事件
*/
const handleFileChange = (event) => {
selectedFile.value = event.target.files[0];
};
/**
* 格式化字节为可读格式
* @param bytes 字节数
* @returns {string} 可读格式的文件大小
*/
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* 检查某个分片是否已经上传
* @param fileName 文件名
* @param chunkNumber 分片编号
* @returns {Promise<boolean>}
*/
const isChunkUploaded = async (fileName, chunkNumber) => {
try {
const response = await axios.get(`/check-chunk-uploaded?fileName=${fileName}&chunkNumber=${chunkNumber}`);
return response.data.uploaded;
} catch (error) {
console.error('Error checking chunk upload status:', error);
return false;
}
};
/**
* 上传文件分片
* @param chunk 分片数据
* @param fileName 文件名
* @param chunkNumber 当前分片编号
* @param totalChunks 总分片数
* @returns {Promise<void>}
*/
const uploadChunk = async (chunk, fileName, chunkNumber, totalChunks) => {
const formData = new FormData();
formData.append('file', new Blob([chunk]), fileName);
formData.append('chunkNumber', chunkNumber);
formData.append('totalChunks', totalChunks);
try {
const response = await axios.post('/upload', formData, {
onUploadProgress: (progressEvent) => {
const percentage = (chunkNumber / totalChunks) * 100 + (progressEvent.loaded / progressEvent.total) * (100 / totalChunks);
uploadProgress.value = Math.min(percentage, 100);
}
});
if (response.status !== 200) {
console.error('Failed to upload chunk');
} else {
uploadedChunks.value.add(chunkNumber); // 记录已上传的分片
}
} catch (error) {
console.error('Error uploading chunk:', error);
}
};
/**
* 合并所有分片
* @param fileName 文件名
* @param totalChunks 总分片数
* @returns {Promise<void>}
*/
const mergeChunks = async (fileName, totalChunks) => {
try {
const response = await axios.post('/merge', { fileName, totalChunks });
if (response.status === 200) {
console.log('All chunks merged successfully.');
} else {
console.error('Failed to merge chunks.');
}
} catch (error) {
console.error('Error merging chunks:', error);
}
};
/**
* 表单提交处理函数
* @returns {Promise<void>}
*/
const handleSubmit = async () => {
if (!selectedFile.value) return;
const file = selectedFile.value;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
let start = 0;
let chunkNumber = 1;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
if (!(await isChunkUploaded(file.name, chunkNumber))) {
await uploadChunk(chunk, file.name, chunkNumber, totalChunks);
}
start = end;
chunkNumber++;
}
await mergeChunks(file.name, totalChunks);
uploadProgress.value = 100;
};
/**
* 加载保存的上传进度
*/
const loadSavedProgress = () => {
const savedProgress = JSON.parse(localStorage.getItem('uploadProgress'));
if (savedProgress && savedProgress.fileName === selectedFile.value?.name) {
uploadedChunks.value = new Set(savedProgress.uploadedChunks);
uploadProgress.value = savedProgress.progress;
}
};
/**
* 定时保存上传进度
*/
const saveUploadProgress = () => {
localStorage.setItem('uploadProgress', JSON.stringify({
fileName: selectedFile.value?.name,
uploadedChunks: Array.from(uploadedChunks.value),
progress: uploadProgress.value,
}));
};
onMounted(() => {
loadSavedProgress(); // 页面加载时恢复上传进度
setInterval(saveUploadProgress, 5000); // 每5秒保存一次上传进度
});
return {
fileInput,
selectedFile,
uploadProgress,
handleFileChange,
formatBytes,
handleSubmit,
};
},
};
</script>
后端(Spring Boot + MinIO)
1. 添加依赖
首先,在 pom.xml
中添加 MinIO 的 Maven 依赖:
XML
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.3</version>
</dependency>
2. 配置 MinIO 客户端
在 Spring Boot 应用中配置 MinIO 客户端:
java
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint("http://localhost:9000") // MinIO 服务地址
.credentials("YOUR-ACCESS-KEY", "YOUR-SECRET-KEY") // MinIO 凭证
.build();
}
}
3. 更新控制器逻辑
更新控制器逻辑以使用 MinIO 客户端上传和合并文件,并提供接口检查分片是否已上传:
java
import io.minio.*;
import io.minio.errors.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ConcurrentHashMap;
@RestController
public class FileUploadController {
private static final String BUCKET_NAME = "your-bucket-name"; // 替换为你的桶名称
private static final ConcurrentHashMap<String, Integer> chunkCountMap = new ConcurrentHashMap<>();
@Autowired
private MinioClient minioClient;
@PostMapping("/upload")
public String uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
String fileName = file.getOriginalFilename();
// 将分片上传到 MinIO
minioClient.putObject(
PutObjectArgs.builder()
.bucket(BUCKET_NAME)
.object(fileName + "_part_" + chunkNumber)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
// 使用ConcurrentHashMap记录每个文件已上传的分片数量
chunkCountMap.merge(fileName, 1, Integer::sum);
return "Uploaded chunk " + chunkNumber + " of " + totalChunks;
}
@GetMapping("/check-chunk-uploaded")
public ResponseEntity<?> checkChunkUploaded(@RequestParam String fileName, @RequestParam int chunkNumber) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
boolean exists = minioClient.statObject(
StatObjectArgs.builder()
.bucket(BUCKET_NAME)
.object(fileName + "_part_" + chunkNumber)
.build()
) != null;
return ResponseEntity.ok(new HashMap<String, Boolean>() {{
put("uploaded", exists);
}});
}
@PostMapping("/merge")
public String mergeChunks(@RequestBody MergeRequest request) throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
String fileName = request.getFileName();
int totalChunks = request.getTotalChunks();
// 创建临时文件用于合并
Path tempFilePath = Paths.get(fileName);
OutputStream outputStream = new FileOutputStream(tempFilePath.toFile());
for (int i = 1; i <= totalChunks; i++) {
InputStream inputStream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(BUCKET_NAME)
.object(fileName + "_part_" + i)
.build()
);
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
inputStream.close();
// 删除临时分片文件
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(BUCKET_NAME)
.object(fileName + "_part_" + i)
.build()
);
}
outputStream.close();
// 将合并后的文件上传到 MinIO
minioClient.putObject(
PutObjectArgs.builder()
.bucket(BUCKET_NAME)
.object(fileName)
.stream(new FileInputStream(tempFilePath.toFile()), Files.size(tempFilePath), -1)
.contentType("application/octet-stream")
.build()
);
// 删除本地临时文件
Files.delete(tempFilePath);
// 清除缓存中的分片计数
chunkCountMap.remove(fileName);
return "Merged all chunks successfully.";
}
static class MergeRequest {
private String fileName;
private int totalChunks;
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public int getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(int totalChunks) {
this.totalChunks = totalChunks;
}
}
}
详细说明
前端部分
-
文件选择输入框:
@change
事件绑定handleFileChange
函数,用于处理文件选择。ref
属性用于引用文件输入框,方便后续操作。
-
文件预览信息:
- 显示选中文件的名称和大小,使用
formatBytes
函数将字节数转换为可读格式。
- 显示选中文件的名称和大小,使用
-
进度条显示:
<progress>
标签用于显示上传进度,value
属性绑定uploadProgress
变量。
-
检查分片是否已上传:
isChunkUploaded
函数通过调用/check-chunk-uploaded
接口检查某个分片是否已经上传。
-
上传文件分片:
uploadChunk
函数负责将文件分片上传到服务器。- 使用
FormData
对象封装分片数据和其他参数。 onUploadProgress
回调函数用于实时更新上传进度。- 成功上传后,将分片编号加入
uploadedChunks
集合中。
-
合并分片:
mergeChunks
函数发送合并请求,通知服务器合并所有分片。
-
表单提交处理:
handleSubmit
函数负责计算总分片数,并依次上传每个分片,最后合并分片。
-
加载保存的上传进度:
loadSavedProgress
函数从localStorage
中加载之前保存的上传进度。saveUploadProgress
函数定时保存当前的上传进度。
后端部分
-
MinIO 客户端配置:
- 在
MinioConfig
类中配置 MinIO 客户端,设置 MinIO 服务地址和凭证。
- 在
-
上传文件分片:
/upload
路由处理文件分片上传请求。- 将分片保存到 MinIO 中,使用
PutObjectArgs
构建上传参数。
-
检查分片是否已上传:
/check-chunk-uploaded
路由处理检查分片是否已上传的请求。- 使用
statObject
方法检查对象是否存在。
-
合并所有分片:
/merge
路由处理合并请求。- 从 MinIO 中读取所有分片文件,按顺序写入最终文件,并删除临时分片文件。
- 最终将合并后的文件上传到 MinIO 中。
- 删除本地临时文件并清除缓存中的分片计数。
其他注意事项
-
MinIO 初始化:
- 确保 MinIO 服务已经启动,并且可以通过提供的 endpoint 访问。
- 替换
YOUR-ACCESS-KEY
和YOUR-SECRET-KEY
为你自己的 MinIO 凭证。
-
错误处理:
- 在实际应用中,建议增加更多的错误处理机制,例如重试机制、超时处理等,以提高系统的健壮性。
-
安全性:
- 在生产环境中,请确保对 MinIO 凭证进行妥善管理,并考虑使用环境变量或配置中心来存储这些敏感信息。
-
用户体验优化:
- 提供用户友好的提示信息,如上传进度、成功或失败的消息等。
- 支持暂停和恢复上传功能,进一步提升用户体验。