基于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. 用户体验优化

    • 提供用户友好的提示信息,如上传进度、成功或失败的消息等。
    • 支持暂停和恢复上传功能,进一步提升用户体验。
相关推荐
gentle coder3 小时前
【框架】Spring、SpringBoot和SpringCloud区别
spring boot·spring·spring cloud
计算机-秋大田5 小时前
基于Spring Boot的乡村养老服务管理系统设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
小萌新上大分6 小时前
Minio搭建并在SpringBoot中使用完成用户头像的上传
java·spring boot·后端·minio·minio搭建·头像上传·minio入门
B站计算机毕业设计超人7 小时前
计算机毕业设计SpringBoot+Vue.js校园失物招领系统(源码+文档+PPT+讲解)
java·vue.js·spring boot·后端·毕业设计·课程设计·毕设
计算机-秋大田7 小时前
基于SpringBoot的环保网站的设计与实现(源码+SQL脚本+LW+部署讲解等)
java·vue.js·spring boot·后端·课程设计
汤姆yu7 小时前
基于springboot的高校物品捐赠系统
java·spring boot·后端·高校物品捐赠
大地爱7 小时前
如何使用Spring Boot框架整合Redis:超详细案例教程
spring boot·redis·后端
Pro_er8 小时前
Vue3 路由配置与导航全攻略:从零到精通
vue·前端开发
土豆炒马铃薯。9 小时前
【Java 基础(人话版)】Java SE vs Java EE
java·开发语言·spring boot·java-ee·java基础·java-se
李豆豆喵9 小时前
第38天:安全开发-JavaEE应用&SpringBoot框架&MyBatis注入&Thymeleaf模版注入
java·spring boot·mybatis