基于vue3和spring boot实现大文件上传

前端(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;
        }
    }
}

详细说明

前端部分
  1. 文件选择输入框

    • @change 事件绑定 handleFileChange 函数,用于处理文件选择。
    • ref 属性用于引用文件输入框,方便后续操作。
  2. 文件预览信息

    • 显示选中文件的名称和大小,使用 formatBytes 函数将字节数转换为可读格式。
  3. 进度条显示

    • <progress> 标签用于显示上传进度,value 属性绑定 uploadProgress 变量。
  4. 检查分片是否已上传

    • isChunkUploaded 函数通过调用 /check-chunk-uploaded 接口检查某个分片是否已经上传。
  5. 上传文件分片

    • uploadChunk 函数负责将文件分片上传到服务器。
    • 使用 FormData 对象封装分片数据和其他参数。
    • onUploadProgress 回调函数用于实时更新上传进度。
    • 成功上传后,将分片编号加入 uploadedChunks 集合中。
  6. 合并分片

    • mergeChunks 函数发送合并请求,通知服务器合并所有分片。
  7. 表单提交处理

    • handleSubmit 函数负责计算总分片数,并依次上传每个分片,最后合并分片。
  8. 加载保存的上传进度

    • loadSavedProgress 函数从 localStorage 中加载之前保存的上传进度。
    • saveUploadProgress 函数定时保存当前的上传进度。
后端部分
  1. MinIO 客户端配置

    • MinioConfig 类中配置 MinIO 客户端,设置 MinIO 服务地址和凭证。
  2. 上传文件分片

    • /upload 路由处理文件分片上传请求。
    • 将分片保存到 MinIO 中,使用 PutObjectArgs 构建上传参数。
  3. 检查分片是否已上传

    • /check-chunk-uploaded 路由处理检查分片是否已上传的请求。
    • 使用 statObject 方法检查对象是否存在。
  4. 合并所有分片

    • /merge 路由处理合并请求。
    • 从 MinIO 中读取所有分片文件,按顺序写入最终文件,并删除临时分片文件。
    • 最终将合并后的文件上传到 MinIO 中。
    • 删除本地临时文件并清除缓存中的分片计数。

其他注意事项

  1. MinIO 初始化

    • 确保 MinIO 服务已经启动,并且可以通过提供的 endpoint 访问。
    • 替换 YOUR-ACCESS-KEYYOUR-SECRET-KEY 为你自己的 MinIO 凭证。
  2. 错误处理

    • 在实际应用中,建议增加更多的错误处理机制,例如重试机制、超时处理等,以提高系统的健壮性。
  3. 安全性

    • 在生产环境中,请确保对 MinIO 凭证进行妥善管理,并考虑使用环境变量或配置中心来存储这些敏感信息。
  4. 用户体验优化

    • 提供用户友好的提示信息,如上传进度、成功或失败的消息等。
    • 支持暂停和恢复上传功能,进一步提升用户体验。
相关推荐
用户908324602739 小时前
Spring AI 1.1.2 + Neo4j:用知识图谱增强 RAG 检索(上篇:图谱构建)
java·spring boot
用户8307196840821 天前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解1 天前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解1 天前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记1 天前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者2 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840822 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解2 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
初次攀爬者3 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺3 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端