前端(Vue 3)
1. 文件选择与分片上传
首先,我们需要一个文件选择器来选择要上传的文件,并将其分割成多个小块(分片)进行上传。我们还需要记录每个分片的上传状态以便支持断点续传。
<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 依赖:
<dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.4.3</version>
</dependency>
2. 配置 MinIO 客户端
在 Spring Boot 应用中配置 MinIO 客户端:
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class MinioConfig {@Beanpublic MinioClient minioClient() {return MinioClient.builder().endpoint("http://localhost:9000") // MinIO 服务地址.credentials("YOUR-ACCESS-KEY", "YOUR-SECRET-KEY") // MinIO 凭证.build();}
}
3. 更新控制器逻辑
更新控制器逻辑以使用 MinIO 客户端上传和合并文件,并提供接口检查分片是否已上传:
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<>();@Autowiredprivate 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();// 将分片上传到 MinIOminioClient.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();// 将合并后的文件上传到 MinIOminioClient.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 凭证进行妥善管理,并考虑使用环境变量或配置中心来存储这些敏感信息。
-
用户体验优化:
- 提供用户友好的提示信息,如上传进度、成功或失败的消息等。
- 支持暂停和恢复上传功能,进一步提升用户体验。